diff --git a/.github/workflows/connection_repository.yaml b/.github/workflows/connection_repository.yaml new file mode 100644 index 0000000..c67865e --- /dev/null +++ b/.github/workflows/connection_repository.yaml @@ -0,0 +1,20 @@ +name: connection_repository + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/connection_repository/**" + - ".github/workflows/connection_repository.yaml" + branches: + - main + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1.14.0 + with: + dart_sdk: stable + working_directory: packages/connection_repository \ No newline at end of file diff --git a/.github/workflows/control_repository.yaml b/.github/workflows/control_repository.yaml new file mode 100644 index 0000000..0d19d56 --- /dev/null +++ b/.github/workflows/control_repository.yaml @@ -0,0 +1,20 @@ +name: control_repository + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/control_repository/**" + - ".github/workflows/control_repository.yaml" + branches: + - main + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1.14.0 + with: + dart_sdk: stable + working_directory: packages/control_repository \ No newline at end of file diff --git a/.github/workflows/gyver_lamp.yaml b/.github/workflows/gyver_lamp.yaml new file mode 100644 index 0000000..c663906 --- /dev/null +++ b/.github/workflows/gyver_lamp.yaml @@ -0,0 +1,9 @@ +name: gyver_lamp + +on: [pull_request, push] + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1.14.0 + with: + flutter_channel: stable \ No newline at end of file diff --git a/.github/workflows/gyver_lamp_client.yaml b/.github/workflows/gyver_lamp_client.yaml new file mode 100644 index 0000000..a8b7af6 --- /dev/null +++ b/.github/workflows/gyver_lamp_client.yaml @@ -0,0 +1,20 @@ +name: gyver_lamp_client + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/gyver_lamp_client/**" + - ".github/workflows/gyver_lamp_client.yaml" + branches: + - main + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1.14.0 + with: + dart_sdk: stable + working_directory: packages/gyver_lamp_client \ No newline at end of file diff --git a/.github/workflows/gyver_lamp_effects.yaml b/.github/workflows/gyver_lamp_effects.yaml new file mode 100644 index 0000000..bf3dd54 --- /dev/null +++ b/.github/workflows/gyver_lamp_effects.yaml @@ -0,0 +1,20 @@ +name: gyver_lamp_effects + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/gyver_lamp_effects/**" + - ".github/workflows/gyver_lamp_effects.yaml" + branches: + - main + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1.14.0 + with: + flutter_channel: stable + working_directory: packages/gyver_lamp_effects \ No newline at end of file diff --git a/.github/workflows/gyver_lamp_icons.yaml b/.github/workflows/gyver_lamp_icons.yaml new file mode 100644 index 0000000..1ff4999 --- /dev/null +++ b/.github/workflows/gyver_lamp_icons.yaml @@ -0,0 +1,20 @@ +name: gyver_lamp_icons + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/gyver_lamp_icons/**" + - ".github/workflows/gyver_lamp_icons.yaml" + branches: + - main + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1.14.0 + with: + flutter_channel: stable + working_directory: packages/gyver_lamp_icons \ No newline at end of file diff --git a/.github/workflows/gyver_lamp_ui.yaml b/.github/workflows/gyver_lamp_ui.yaml new file mode 100644 index 0000000..78d935d --- /dev/null +++ b/.github/workflows/gyver_lamp_ui.yaml @@ -0,0 +1,20 @@ +name: gyver_lamp_ui + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/gyver_lamp_ui/**" + - ".github/workflows/gyver_lamp_ui.yaml" + branches: + - main + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1.14.0 + with: + flutter_channel: stable + working_directory: packages/gyver_lamp_ui \ No newline at end of file diff --git a/.github/workflows/settings_controller.yaml b/.github/workflows/settings_controller.yaml new file mode 100644 index 0000000..31e06e2 --- /dev/null +++ b/.github/workflows/settings_controller.yaml @@ -0,0 +1,20 @@ +name: settings_controller + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/settings_controller/**" + - ".github/workflows/settings_controller.yaml" + branches: + - main + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1.14.0 + with: + flutter_channel: stable + working_directory: packages/settings_controller \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0f999b --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +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 +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# 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 + +# Test related +coverage + +# Ignore golden tests failures +test/**/failures/**/*.* \ No newline at end of file diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..5ad1be7 --- /dev/null +++ b/.metadata @@ -0,0 +1,36 @@ +# 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. + +version: + revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + channel: stable + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + base_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + - platform: android + create_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + base_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + - platform: ios + create_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + base_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + - platform: macos + create_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + base_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf + + # 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/LICENSE b/LICENSE new file mode 100644 index 0000000..52bd5eb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Kostia Sokolovskyi + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..520df18 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Gyver Lamp + + + +[![gyver_lamp][build_status_badge]][workflow_link] +![coverage][coverage_badge] +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +A companion application for [GyverLamp][gyver_lamp_repo_link] build with [Flutter][flutter_link]. + +Huge thanks to [Iryna][dribbble_link] for the design and support ❤️ + +--- + +## Application Highlights ✨ + +### Animated splash screen made with [Rive][rive_link] + +Thanks to [JcToon][rive_animation_link] for publishing the animation. + +### Straightforward connection process and automatic reconnect + +### Animated preview of all supported effects + +### Intuitive controls with real-time preview + +### Multi-language support + +### Dark mode support + +--- + +## Getting Started 🚀 + +This project contains 2 entry points: + +- development +- production + +To run the desired entry point either use the launch configuration in VSCode/Android Studio or use the following commands: + +```sh +# Development +$ flutter run --target lib/main_development.dart + +# Production +$ flutter run --target lib/main_production.dart +``` + +[workflow_link]: https://github.com/ksokolovskyi/gyver_lamp/actions/workflows/gyver_lamp.yaml +[build_status_badge]: https://github.com/ksokolovskyi/gyver_lamp/actions/workflows/gyver_lamp.yaml/badge.svg +[coverage_badge]: coverage_badge.svg +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[gyver_lamp_repo_link]: https://github.com/AlexGyver/GyverLamp +[flutter_link]: https://flutter.dev +[dribbble_link]: https://dribbble.com/ira_dehtiar +[rive_link]: https://rive.app/ +[rive_animation_link]: https://rive.app/community/450-872-onoff-switch/ diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..6f90af1 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:very_good_analysis/analysis_options.4.0.0.yaml + +linter: + rules: + public_member_api_docs: false \ No newline at end of file diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..6f56801 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..49a2bd8 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,71 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 34 + ndkVersion "25.1.8937393" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.example.gyver_lamp" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + minSdkVersion 21 + targetSdkVersion 34 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + 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.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..119c6b4 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f737c03 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/gyver_lamp/MainActivity.kt b/android/app/src/main/kotlin/com/example/gyver_lamp/MainActivity.kt new file mode 100644 index 0000000..bb6c4da --- /dev/null +++ b/android/app/src/main/kotlin/com/example/gyver_lamp/MainActivity.kt @@ -0,0 +1,19 @@ +package com.example.gyver_lamp + +import android.os.Build +import android.os.Bundle +import androidx.core.view.WindowCompat +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Aligns the Flutter view vertically with the window. + WindowCompat.setDecorFitsSystemWindows(getWindow(), false) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + splashScreen.setOnExitAnimationListener { it.remove() } + } + + super.onCreate(savedInstanceState) + } +} \ No newline at end of file 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..d3f4435 --- /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..15fee23 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..a2b0767 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..9260fe9 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..55ca3c3 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..981ac0d 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..5ea14af --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..7bd1ff8 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #142230 + \ No newline at end of file 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..3cbf328 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..119c6b4 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..713d7f6 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.2.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..94adc3a --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3c472b9 --- /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-7.5-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..44e62bc --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/art/logo.png b/art/logo.png new file mode 100644 index 0000000..cde88c8 Binary files /dev/null and b/art/logo.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..0922db4 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/switch.riv b/assets/switch.riv new file mode 100644 index 0000000..e509c24 Binary files /dev/null and b/assets/switch.riv differ diff --git a/coverage_badge.svg b/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/flutter_launcher_icons.yaml b/flutter_launcher_icons.yaml new file mode 100644 index 0000000..4fbed19 --- /dev/null +++ b/flutter_launcher_icons.yaml @@ -0,0 +1,9 @@ +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/icon.png" + min_sdk_android: 21 + remove_alpha_ios: true + macos: + generate: true + image_path: "assets/icon.png" \ No newline at end of file diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..9625e10 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 11.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..3cf902c --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +source 'https://github.com/CocoaPods/Specs.git' + +# Uncomment this line to define a global platform for your project +platform :ios, '11.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..1ea3305 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,35 @@ +PODS: + - Flutter (1.0.0) + - rive_common (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - rive_common (from `.symlinks/plugins/rive_common/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + rive_common: + :path: ".symlinks/plugins/rive_common/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + rive_common: 8a159d68033a8b073e5853acc50f03aa486a2888 + shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 + +PODFILE CHECKSUM: 216a07bd17c29f43047bcc707524116833a4fb33 + +COCOAPODS: 1.13.0 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d883fa1 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,554 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 6F8D46B4E474F79E5C970091 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4DC7FF63247FF7ADFD603BFB /* Pods_Runner.framework */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1DA8E87B403DE78FFFC11EE2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4DC7FF63247FF7ADFD603BFB /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 57905869A374CBF40A8A868C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8AC28638C7AF88BA36881E5B /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 6F8D46B4E474F79E5C970091 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 02EA374EEC50A582B370136A /* Pods */ = { + isa = PBXGroup; + children = ( + 1DA8E87B403DE78FFFC11EE2 /* Pods-Runner.debug.xcconfig */, + 8AC28638C7AF88BA36881E5B /* Pods-Runner.release.xcconfig */, + 57905869A374CBF40A8A868C /* Pods-Runner.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 74E06F7B847E9DC18B1CAE17 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 4DC7FF63247FF7ADFD603BFB /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 02EA374EEC50A582B370136A /* Pods */, + 74E06F7B847E9DC18B1CAE17 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 130D3AACB0A548FC910CB5FD /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + BB93B1A5F3ABDAFDCC4E058B /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 130D3AACB0A548FC910CB5FD /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + BB93B1A5F3ABDAFDCC4E058B /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = Z7LR8J7Z5L; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.gyverLamp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = Z7LR8J7Z5L; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.gyverLamp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = Z7LR8J7Z5L; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.gyverLamp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..a6b826d --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..70693e4 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..461d831 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..8ae0159 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..1b34c1e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..431949c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..aa95dda Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..a008fe3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..6a1318f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..1b34c1e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..e929f61 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..8d8a4e9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..beb7a00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..e4724e0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..69659f4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..eb5fc41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..8d8a4e9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..fe7cd0d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..8f80943 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..7b160e2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..63167c1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..b25f8a2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..ae115ee Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..71a03a7 --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..246b6fe --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,64 @@ + + + + + CFBundleLocalizations + + en + ru + uk + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Gyver Lamp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + gyver_lamp + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + LSApplicationQueriesSchemes + + https + mailto + + FLTEnableImpeller + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..6f72a55 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n/arb +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +nullable-getter: false diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 0000000..6de8374 --- /dev/null +++ b/lib/app/app.dart @@ -0,0 +1,2 @@ +export 'models/models.dart'; +export 'view/view.dart'; diff --git a/lib/app/models/app_data.dart b/lib/app/models/app_data.dart new file mode 100644 index 0000000..68375f1 --- /dev/null +++ b/lib/app/models/app_data.dart @@ -0,0 +1,27 @@ +import 'package:connection_repository/connection_repository.dart'; +import 'package:control_repository/control_repository.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/initial_setup/initial_setup.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class AppData { + const AppData({ + required this.connectionRepository, + required this.controlRepository, + required this.settingsController, + required this.initialConnectionData, + required this.initialSetupCompleted, + }); + + final ConnectionRepository connectionRepository; + + final ControlRepository controlRepository; + + final SettingsController settingsController; + + final ConnectionData? initialConnectionData; + + /// Whether to show [InitialSetupPage] or [ControlPage] after startup. + final bool initialSetupCompleted; +} diff --git a/lib/app/models/models.dart b/lib/app/models/models.dart new file mode 100644 index 0000000..c8639a8 --- /dev/null +++ b/lib/app/models/models.dart @@ -0,0 +1 @@ +export 'app_data.dart'; diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart new file mode 100644 index 0000000..2def3d6 --- /dev/null +++ b/lib/app/view/app.dart @@ -0,0 +1,102 @@ +import 'package:control_repository/control_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/app/app.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/initial_setup/initial_setup.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:provider/provider.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class App extends StatefulWidget { + const App({ + required this.data, + super.key, + }); + + final AppData data; + + @override + State createState() => _AppState(); +} + +class _AppState extends State { + late final settingsListenable = Listenable.merge([ + widget.data.settingsController.locale, + widget.data.settingsController.darkModeOn, + ]); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + Provider.value( + value: widget.data.settingsController, + ), + Provider.value( + value: widget.data.controlRepository, + ), + BlocProvider( + lazy: false, + create: (context) { + return ConnectionBloc( + connectionRepository: widget.data.connectionRepository, + settingsController: widget.data.settingsController, + initialConnectionData: widget.data.initialConnectionData, + )..add( + const ConnectionRequested(), + ); + }, + ), + ], + child: ListenableBuilder( + listenable: settingsListenable, + builder: (context, _) { + final locale = widget.data.settingsController.locale.value; + final darkModeOn = widget.data.settingsController.darkModeOn.value; + + final themeMode = darkModeOn == null + ? ThemeMode.system + : (darkModeOn ? ThemeMode.dark : ThemeMode.system); + + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Gyver Lamp', + theme: GyverLampTheme.lightThemeData, + darkTheme: GyverLampTheme.darkThemeData, + themeMode: themeMode, + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: widget.data.initialSetupCompleted + ? const ControlPage() + : const InitialSetupView(), + builder: (context, child) { + final theme = Theme.of(context); + final colors = theme.extension()!; + final brightness = theme.brightness; + + final overlayStyle = brightness == Brightness.dark + ? SystemUiOverlayStyle.light + : SystemUiOverlayStyle.dark; + + return AnnotatedRegion( + value: overlayStyle.copyWith( + systemNavigationBarColor: colors.background, + systemNavigationBarIconBrightness: + brightness == Brightness.dark + ? Brightness.light + : Brightness.dark, + ), + child: AlertMessenger(child: child!), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/app/view/app_loader.dart b/lib/app/view/app_loader.dart new file mode 100644 index 0000000..63561f9 --- /dev/null +++ b/lib/app/view/app_loader.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:gyver_lamp/app/app.dart'; +import 'package:gyver_lamp/splash/splash.dart'; + +class AppLoader extends StatefulWidget { + const AppLoader({ + required this.dataLoader, + super.key, + }); + + final Future Function() dataLoader; + + @override + State createState() => _AppLoaderState(); +} + +class _AppLoaderState extends State { + AppData? _data; + + @override + void initState() { + super.initState(); + _subscribe(); + } + + void _subscribe() { + widget.dataLoader().then((data) { + Future.delayed( + SplashPage.kFadeDuration + SplashPage.kAnimationDuration, + () { + if (mounted) { + setState(() => _data = data); + } + }, + ); + }); + } + + @override + Widget build(BuildContext context) { + final data = _data; + + if (data == null) { + return const SplashPage(); + } + + return App(data: data); + } +} diff --git a/lib/app/view/view.dart b/lib/app/view/view.dart new file mode 100644 index 0000000..04d599b --- /dev/null +++ b/lib/app/view/view.dart @@ -0,0 +1,2 @@ +export 'app.dart'; +export 'app_loader.dart'; diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart new file mode 100644 index 0000000..4a906cb --- /dev/null +++ b/lib/bootstrap.dart @@ -0,0 +1,59 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:logging/logging.dart'; + +class AppBlocObserver extends BlocObserver { + const AppBlocObserver(); + + @override + void onChange(BlocBase bloc, Change change) { + super.onChange(bloc, change); + log('bloc.onChange(${bloc.runtimeType}, $change)'); + } + + @override + void onError(BlocBase bloc, Object error, StackTrace stackTrace) { + log('bloc.onError(${bloc.runtimeType}, $error, $stackTrace)'); + super.onError(bloc, error, stackTrace); + } +} + +typedef BootstrapBuilder = Widget Function(); + +Future bootstrap(BootstrapBuilder builder) async { + FlutterError.onError = (details) { + log( + details.exceptionAsString(), + stackTrace: details.stack, + ); + }; + + Bloc.observer = const AppBlocObserver(); + + await runZonedGuarded( + () async { + WidgetsFlutterBinding.ensureInitialized(); + + if (kReleaseMode) { + // Don't log anything below warnings in production. + Logger.root.level = Level.WARNING; + } + + Logger.root.onRecord.listen((record) { + debugPrint('${record.level.name}: ${record.time}: ' + '${record.loggerName}: ' + '${record.message}'); + }); + + runApp(builder()); + }, + (error, stackTrace) => log( + error.toString(), + stackTrace: stackTrace, + ), + ); +} diff --git a/lib/connection/bloc/connection_bloc.dart b/lib/connection/bloc/connection_bloc.dart new file mode 100644 index 0000000..3a737c7 --- /dev/null +++ b/lib/connection/bloc/connection_bloc.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:connection_repository/connection_repository.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:settings_controller/settings_controller.dart'; + +part 'connection_event.dart'; +part 'connection_state.dart'; + +class ConnectionBloc extends Bloc { + ConnectionBloc({ + required ConnectionRepository connectionRepository, + required SettingsController settingsController, + ConnectionData? initialConnectionData, + }) : _connectionRepository = connectionRepository, + _settingsController = settingsController, + super( + ConnectionInitial( + address: initialConnectionData != null + ? IpAddressInput.dirty(initialConnectionData.address) + : IpAddressInput.pure(), + port: initialConnectionData != null + ? PortInput.dirty(initialConnectionData.port) + : PortInput.pure(), + ), + ) { + on(_onIpAddressUpdated); + on(_onPortUpdated); + on(_onConnectionDataCheckRequested); + on(_onConnectionRequested); + on(_onDisconnectionRequested); + on(_onConnectionStatusUpdated); + } + + @override + Future close() async { + await _unsubscribeFromStatuses(); + return super.close(); + } + + Future _unsubscribeFromStatuses() async { + await _statusesSubscription?.cancel(); + _statusesSubscription = null; + } + + final ConnectionRepository _connectionRepository; + + final SettingsController _settingsController; + + StreamSubscription? _statusesSubscription; + + Future _onIpAddressUpdated( + IpAddressUpdated event, + Emitter emit, + ) async { + emit( + ConnectionInitial( + address: IpAddressInput.dirty(event.address ?? ''), + port: state.port, + ), + ); + } + + Future _onPortUpdated( + PortUpdated event, + Emitter emit, + ) async { + emit( + ConnectionInitial( + address: state.address, + port: PortInput.dirty(event.port ?? -1), + ), + ); + } + + Future _onConnectionDataCheckRequested( + ConnectionDataCheckRequested event, + Emitter emit, + ) async { + emit( + state.copyWith( + address: state.address.isValid ? state.address : IpAddressInput.pure(), + port: state.port.isValid ? state.port : PortInput.pure(), + ), + ); + } + + Future _onConnectionRequested( + ConnectionRequested event, + Emitter emit, + ) async { + final connectionData = state.connectionData; + + if (connectionData == null) { + return; + } + + emit( + ConnectionInProgress( + address: state.address, + port: state.port, + ), + ); + + await Future.delayed( + const Duration(milliseconds: 350), + ); + + try { + await _connectionRepository.connect( + address: connectionData.address, + port: connectionData.port, + ); + + // Saving latest address and port for the future sessions. + _settingsController + ..setIpAddress(ipAddress: connectionData.address) + ..setPort(port: connectionData.port); + + emit( + ConnectionSuccess( + address: state.address, + port: state.port, + ), + ); + } catch (e, t) { + addError(e, t); + + emit( + ConnectionFailure( + address: state.address, + port: state.port, + ), + ); + + return; + } + + _statusesSubscription = _connectionRepository.statuses.listen( + (status) => add( + ConnectionStatusUpdated(status: status), + ), + onDone: () => add( + const ConnectionStatusUpdated( + status: ConnectionStatus.disconnected, + ), + ), + ); + } + + Future _onDisconnectionRequested( + DisconnectionRequested event, + Emitter emit, + ) async { + await _connectionRepository.disconnect(); + await _unsubscribeFromStatuses(); + + emit( + ConnectionInitial( + address: state.address, + port: state.port, + ), + ); + } + + Future _onConnectionStatusUpdated( + ConnectionStatusUpdated event, + Emitter emit, + ) async { + switch (event.status) { + case ConnectionStatus.connecting: + emit( + ConnectionInProgress( + address: state.address, + port: state.port, + ), + ); + + case ConnectionStatus.connected: + emit( + ConnectionSuccess( + address: state.address, + port: state.port, + ), + ); + + case ConnectionStatus.disconnected: + await _unsubscribeFromStatuses(); + + emit( + ConnectionInitial( + address: state.address, + port: state.port, + ), + ); + } + } +} diff --git a/lib/connection/bloc/connection_event.dart b/lib/connection/bloc/connection_event.dart new file mode 100644 index 0000000..0f07305 --- /dev/null +++ b/lib/connection/bloc/connection_event.dart @@ -0,0 +1,53 @@ +part of 'connection_bloc.dart'; + +sealed class ConnectionEvent extends Equatable { + const ConnectionEvent(); + + @override + List get props => []; +} + +class IpAddressUpdated extends ConnectionEvent { + const IpAddressUpdated({ + required this.address, + }); + + final String? address; + + @override + List get props => [address]; +} + +class PortUpdated extends ConnectionEvent { + const PortUpdated({ + required this.port, + }); + + final int? port; + + @override + List get props => [port]; +} + +class ConnectionDataCheckRequested extends ConnectionEvent { + const ConnectionDataCheckRequested(); +} + +class ConnectionRequested extends ConnectionEvent { + const ConnectionRequested(); +} + +class DisconnectionRequested extends ConnectionEvent { + const DisconnectionRequested(); +} + +class ConnectionStatusUpdated extends ConnectionEvent { + const ConnectionStatusUpdated({ + required this.status, + }); + + final ConnectionStatus status; + + @override + List get props => [status]; +} diff --git a/lib/connection/bloc/connection_state.dart b/lib/connection/bloc/connection_state.dart new file mode 100644 index 0000000..cd135bb --- /dev/null +++ b/lib/connection/bloc/connection_state.dart @@ -0,0 +1,104 @@ +part of 'connection_bloc.dart'; + +extension ConnectionStateX on ConnectionState { + bool get isConnected => this is ConnectionSuccess; + + bool get isConnecting => this is ConnectionInProgress; +} + +sealed class ConnectionState extends Equatable { + const ConnectionState({ + required this.address, + required this.port, + }); + + final IpAddressInput address; + + final PortInput port; + + bool get isLampDataValid => Formz.validate([address, port]); + + ConnectionData? get connectionData => isLampDataValid + ? ConnectionData(address: address.value, port: port.value) + : null; + + ConnectionState copyWith({ + IpAddressInput? address, + PortInput? port, + }); + + @override + List get props => [address, port]; +} + +class ConnectionInitial extends ConnectionState { + const ConnectionInitial({ + required super.address, + required super.port, + }); + + @override + ConnectionInitial copyWith({ + IpAddressInput? address, + PortInput? port, + }) { + return ConnectionInitial( + address: address ?? this.address, + port: port ?? this.port, + ); + } +} + +class ConnectionInProgress extends ConnectionState { + const ConnectionInProgress({ + required super.address, + required super.port, + }); + + @override + ConnectionInProgress copyWith({ + IpAddressInput? address, + PortInput? port, + }) { + return ConnectionInProgress( + address: address ?? this.address, + port: port ?? this.port, + ); + } +} + +class ConnectionSuccess extends ConnectionState { + const ConnectionSuccess({ + required super.address, + required super.port, + }); + + @override + ConnectionSuccess copyWith({ + IpAddressInput? address, + PortInput? port, + }) { + return ConnectionSuccess( + address: address ?? this.address, + port: port ?? this.port, + ); + } +} + +class ConnectionFailure extends ConnectionState { + const ConnectionFailure({ + required super.address, + required super.port, + }); + + @override + ConnectionFailure copyWith({ + IpAddressInput? address, + PortInput? port, + }) { + return ConnectionFailure( + address: address ?? this.address, + port: port ?? this.port, + ); + } +} diff --git a/lib/connection/connection.dart b/lib/connection/connection.dart new file mode 100644 index 0000000..065d17b --- /dev/null +++ b/lib/connection/connection.dart @@ -0,0 +1,3 @@ +export 'bloc/connection_bloc.dart'; +export 'models/models.dart'; +export 'widgets/widgets.dart'; diff --git a/lib/connection/models/connection_data.dart b/lib/connection/models/connection_data.dart new file mode 100644 index 0000000..08d6f9c --- /dev/null +++ b/lib/connection/models/connection_data.dart @@ -0,0 +1,15 @@ +import 'package:equatable/equatable.dart'; + +final class ConnectionData extends Equatable { + const ConnectionData({ + required this.address, + required this.port, + }); + + final String address; + + final int port; + + @override + List get props => [address, port]; +} diff --git a/lib/connection/models/ip_address_input.dart b/lib/connection/models/ip_address_input.dart new file mode 100644 index 0000000..971f347 --- /dev/null +++ b/lib/connection/models/ip_address_input.dart @@ -0,0 +1,20 @@ +import 'package:formz/formz.dart'; + +enum IpAddressInputValidationError { invalid } + +class IpAddressInput extends FormzInput + with FormzInputErrorCacheMixin { + IpAddressInput.pure([super.value = '']) : super.pure(); + + IpAddressInput.dirty([super.value = '']) : super.dirty(); + + static final _ipRegExp = + RegExp(r'^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$'); + + @override + IpAddressInputValidationError? validator(String value) { + return _ipRegExp.hasMatch(value) + ? null + : IpAddressInputValidationError.invalid; + } +} diff --git a/lib/connection/models/models.dart b/lib/connection/models/models.dart new file mode 100644 index 0000000..89301c8 --- /dev/null +++ b/lib/connection/models/models.dart @@ -0,0 +1,3 @@ +export 'connection_data.dart'; +export 'ip_address_input.dart'; +export 'port_input.dart'; diff --git a/lib/connection/models/port_input.dart b/lib/connection/models/port_input.dart new file mode 100644 index 0000000..ffaa5b4 --- /dev/null +++ b/lib/connection/models/port_input.dart @@ -0,0 +1,19 @@ +import 'package:formz/formz.dart'; + +enum PortInputValidationError { invalid } + +class PortInput extends FormzInput + with FormzInputErrorCacheMixin { + PortInput.pure([super.value = -1]) : super.pure(); + + PortInput.dirty([super.value = -1]) : super.dirty(); + + @override + PortInputValidationError? validator(int value) { + if (value < 0 || value > 65535) { + return PortInputValidationError.invalid; + } + + return null; + } +} diff --git a/lib/connection/widgets/connect_button.dart b/lib/connection/widgets/connect_button.dart new file mode 100644 index 0000000..63281ba --- /dev/null +++ b/lib/connection/widgets/connect_button.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +enum _ConnectButtonSize { + medium, + large, +} + +class ConnectButton extends StatelessWidget { + const ConnectButton.medium({ + super.key, + }) : _size = _ConnectButtonSize.medium; + + const ConnectButton.large({ + super.key, + }) : _size = _ConnectButtonSize.large; + + final _ConnectButtonSize _size; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return BlocBuilder( + buildWhen: (p, c) { + return p.isLampDataValid != c.isLampDataValid || + p.isConnecting || + c.isConnecting; + }, + builder: (context, state) { + return RoundedElevatedButton( + size: switch (_size) { + _ConnectButtonSize.medium => RoundedElevatedButtonSize.medium, + _ConnectButtonSize.large => RoundedElevatedButtonSize.large, + }, + onPressed: !state.isConnecting && state.isLampDataValid + ? () { + context.read().add( + const ConnectionRequested(), + ); + } + : null, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + reverseDuration: const Duration(milliseconds: 150), + switchInCurve: Curves.easeOutBack, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: ScaleTransition( + filterQuality: FilterQuality.medium, + scale: Tween( + begin: 0, + end: 1, + ).animate(animation), + child: child, + ), + ); + }, + layoutBuilder: ( + Widget? currentChild, + List previousChildren, + ) { + return Stack( + alignment: Alignment.center, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + child: state.isConnecting + // Need this Stack to make button width remain the same when + // loading indicator is shown. + ? Stack( + alignment: Alignment.center, + children: [ + Opacity( + opacity: 0, + child: Text(context.l10n.connect), + ), + CirclesWaveLoadingIndicator( + size: 8, + color: theme.background, + ), + ], + ) + : Text(context.l10n.connect), + ), + ); + }, + ); + } +} diff --git a/lib/connection/widgets/connect_dialog.dart b/lib/connection/widgets/connect_dialog.dart new file mode 100644 index 0000000..f39e558 --- /dev/null +++ b/lib/connection/widgets/connect_dialog.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class ConnectDialog extends StatelessWidget { + const ConnectDialog({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return BlocListener( + listenWhen: (p, c) => c is ConnectionSuccess || c is ConnectionFailure, + listener: (context, state) { + switch (state) { + case ConnectionSuccess(): + _closeDialog(context); + + case ConnectionFailure(): + AlertMessenger.of(context).showError( + message: l10n.connectionFailed, + ); + + default: + // Ignore. + } + }, + child: GyverLampDialog( + title: l10n.connectDialogTitle, + body: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + IpAddressField(), + GyverLampGaps.lg, + PortField(), + ], + ), + actions: [ + RoundedOutlinedButton.medium( + onPressed: () => _closeDialog(context), + child: Text(l10n.cancel), + ), + const ConnectButton.medium(), + ], + ), + ); + } + + void _closeDialog(BuildContext context) { + // Clear all the alerts. + AlertMessenger.of(context).clear(); + + // Resetting connection data if it is not valid. + context.read().add( + const ConnectionDataCheckRequested(), + ); + + Navigator.of(context).maybePop(); + } +} diff --git a/lib/connection/widgets/connection_status_indicator.dart b/lib/connection/widgets/connection_status_indicator.dart new file mode 100644 index 0000000..20c5bd4 --- /dev/null +++ b/lib/connection/widgets/connection_status_indicator.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class ConnectionStatusIndicator extends StatelessWidget { + const ConnectionStatusIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (p, c) => p.runtimeType != c.runtimeType, + builder: (context, state) { + final l10n = context.l10n; + + final status = switch (state) { + ConnectionInitial() || + ConnectionFailure() => + ConnectionStatus.notConnected, + ConnectionInProgress() => ConnectionStatus.connecting, + ConnectionSuccess() => ConnectionStatus.connected, + }; + + return ConnectionStatusBadge( + status: status, + label: (status) { + return switch (status) { + ConnectionStatus.notConnected => l10n.notConnected, + ConnectionStatus.connecting => l10n.connecting, + ConnectionStatus.connected => l10n.connected, + }; + }, + onPressed: switch (status) { + ConnectionStatus.notConnected => () { + final bloc = context.read(); + + GyverLampDialog.show( + context, + dialog: BlocProvider.value( + value: bloc, + child: const ConnectDialog(), + ), + ); + }, + ConnectionStatus.connecting => null, + ConnectionStatus.connected => () { + final bloc = context.read(); + + GyverLampDialog.show( + context, + dialog: BlocProvider.value( + value: bloc, + child: const DisconnectDialog(), + ), + ); + }, + }, + ); + }, + ); + } +} diff --git a/lib/connection/widgets/disconnect_dialog.dart b/lib/connection/widgets/disconnect_dialog.dart new file mode 100644 index 0000000..1cfd71b --- /dev/null +++ b/lib/connection/widgets/disconnect_dialog.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class DisconnectDialog extends StatelessWidget { + const DisconnectDialog({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + final bloc = context.read(); + + return ConfirmationDialog( + title: l10n.disconnectDialogTitle, + body: l10n.disconnectDialogBody, + cancelLabel: l10n.cancel, + confirmLabel: l10n.disconnect, + onCancel: () {}, + onConfirm: () { + bloc.add( + const DisconnectionRequested(), + ); + }, + ); + } +} diff --git a/lib/connection/widgets/ip_address_field.dart b/lib/connection/widgets/ip_address_field.dart new file mode 100644 index 0000000..2372c9a --- /dev/null +++ b/lib/connection/widgets/ip_address_field.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class IpAddressField extends StatefulWidget { + const IpAddressField({super.key}); + + @override + State createState() => _IpAddressFieldState(); +} + +class _IpAddressFieldState extends State { + TextEditingController? _controller; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + if (_controller == null) { + final text = context.read().state.address.value; + _controller = TextEditingController(text: text); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return BlocBuilder( + buildWhen: (p, c) { + return p.address.isValid != c.address.isValid || + p.address.isPure != c.address.isPure || + p.isConnecting || + c.isConnecting; + }, + builder: (context, state) { + return LabeledInputField( + controller: _controller, + label: l10n.ip, + hintText: 'XXX.XXX.XXX.XXX', + errorText: state.address.isValid || state.address.isPure + ? null + : l10n.ipErrorHint, + keyboardType: TextInputType.datetime, + enabled: !state.isConnecting, + onChanged: (address) { + context.read().add( + IpAddressUpdated(address: address), + ); + }, + ); + }, + ); + } +} diff --git a/lib/connection/widgets/port_field.dart b/lib/connection/widgets/port_field.dart new file mode 100644 index 0000000..10d4236 --- /dev/null +++ b/lib/connection/widgets/port_field.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class PortField extends StatefulWidget { + const PortField({super.key}); + + @override + State createState() => _PortFieldState(); +} + +class _PortFieldState extends State { + TextEditingController? _controller; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + if (_controller == null) { + final port = context.read().state.port; + final text = port.isPure ? '' : port.value.toString(); + _controller = TextEditingController(text: text); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return BlocBuilder( + buildWhen: (p, c) { + return p.port.isValid != c.port.isValid || + p.port.isPure != c.port.isPure || + p.isConnecting || + c.isConnecting; + }, + builder: (context, state) { + return LabeledInputField( + controller: _controller, + label: l10n.port, + hintText: 'XXXX', + errorText: state.port.isValid || state.port.isPure + ? null + : l10n.portErrorHint, + keyboardType: TextInputType.number, + enabled: !state.isConnecting, + onChanged: (port) { + context.read().add( + PortUpdated(port: int.tryParse(port)), + ); + }, + ); + }, + ); + } +} diff --git a/lib/connection/widgets/widgets.dart b/lib/connection/widgets/widgets.dart new file mode 100644 index 0000000..305a875 --- /dev/null +++ b/lib/connection/widgets/widgets.dart @@ -0,0 +1,6 @@ +export 'connect_button.dart'; +export 'connect_dialog.dart'; +export 'connection_status_indicator.dart'; +export 'disconnect_dialog.dart'; +export 'ip_address_field.dart'; +export 'port_field.dart'; diff --git a/lib/control/bloc/control_bloc.dart b/lib/control/bloc/control_bloc.dart new file mode 100644 index 0000000..c58ece6 --- /dev/null +++ b/lib/control/bloc/control_bloc.dart @@ -0,0 +1,294 @@ +import 'dart:async'; + +import 'package:control_repository/control_repository.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/connection/connection.dart'; + +part 'control_event.dart'; +part 'control_state.dart'; + +class ControlBloc extends Bloc { + ControlBloc({ + required ControlRepository controlRepository, + required bool isConnected, + required ConnectionData? connectionData, + }) : _controlRepository = controlRepository, + super( + ControlState( + isConnected: isConnected, + connectionData: connectionData, + mode: GyverLampMode.fromIndex(0), + brightness: 128, + speed: 30, + scale: 10, + isOn: false, + ), + ) { + on(_onControlRequested); + on(_onConnectionStateUpdated); + on(_onLampMessageReceived); + on(_onModeUpdated); + on(_onBrightnessUpdated); + on(_onSpeedUpdated); + on(_onScaleUpdated); + on(_onPowerToggled); + } + + final ControlRepository _controlRepository; + + StreamSubscription? _messageSubscription; + + @override + Future close() async { + await _unsubscribeFromMessages(); + return super.close(); + } + + Future _subscribeToMessages() async { + await _unsubscribeFromMessages(); + + _messageSubscription = _controlRepository.messages.listen( + (message) { + add( + LampMessageReceived(message: message), + ); + }, + ); + } + + Future _unsubscribeFromMessages() async { + await _messageSubscription?.cancel(); + _messageSubscription = null; + } + + Future _onControlRequested( + ControlRequested event, + Emitter emit, + ) async { + if (!state.isConnected) { + return; + } + + final connectionData = state.connectionData; + + if (connectionData == null) { + return; + } + + try { + await _subscribeToMessages(); + + await _controlRepository.requestCurrentState( + address: connectionData.address, + port: connectionData.port, + ); + } catch (e, t) { + addError(e, t); + } + } + + Future _onConnectionStateUpdated( + ConnectionStateUpdated event, + Emitter emit, + ) async { + emit( + state.copyWithConnectionState( + isConnected: event.isConnected, + connectionData: event.connectionData, + ), + ); + + if (event.isConnected) { + add(const ControlRequested()); + } else { + await _unsubscribeFromMessages(); + } + } + + Future _onLampMessageReceived( + LampMessageReceived event, + Emitter emit, + ) async { + final message = event.message; + + switch (message) { + case GyverLampStateChangedMessage(): + emit( + state.copyWith( + mode: message.mode, + brightness: message.brightness, + speed: message.speed, + scale: message.scale, + isOn: message.isOn, + ), + ); + + case GyverLampBrightnessChangedMessage(): + emit( + state.copyWith(brightness: message.brightness), + ); + + case GyverLampSpeedChangedMessage(): + emit( + state.copyWith(speed: message.speed), + ); + + case GyverLampScaleChangedMessage(): + emit( + state.copyWith(scale: message.scale), + ); + } + } + + Future _onModeUpdated( + ModeUpdated event, + Emitter emit, + ) async { + emit( + state.copyWith(mode: event.mode), + ); + + if (!state.isConnected) { + return; + } + + final connectionData = state.connectionData; + + if (connectionData == null) { + return; + } + + try { + await _controlRepository.setMode( + address: connectionData.address, + port: connectionData.port, + mode: event.mode, + ); + } catch (e, t) { + addError(e, t); + } + } + + Future _onBrightnessUpdated( + BrightnessUpdated event, + Emitter emit, + ) async { + emit( + state.copyWith(brightness: event.brightness), + ); + + if (!state.isConnected) { + return; + } + + final connectionData = state.connectionData; + + if (connectionData == null) { + return; + } + + try { + await _controlRepository.setBrightness( + address: connectionData.address, + port: connectionData.port, + brightness: event.brightness, + ); + } catch (e, t) { + addError(e, t); + } + } + + Future _onSpeedUpdated( + SpeedUpdated event, + Emitter emit, + ) async { + emit( + state.copyWith(speed: event.speed), + ); + + if (!state.isConnected) { + return; + } + + final connectionData = state.connectionData; + + if (connectionData == null) { + return; + } + + try { + await _controlRepository.setSpeed( + address: connectionData.address, + port: connectionData.port, + speed: event.speed, + ); + } catch (e, t) { + addError(e, t); + } + } + + Future _onScaleUpdated( + ScaleUpdated event, + Emitter emit, + ) async { + emit( + state.copyWith(scale: event.scale), + ); + + if (!state.isConnected) { + return; + } + + final connectionData = state.connectionData; + + if (connectionData == null) { + return; + } + + try { + await _controlRepository.setScale( + address: connectionData.address, + port: connectionData.port, + scale: event.scale, + ); + } catch (e, t) { + addError(e, t); + } + } + + Future _onPowerToggled( + PowerToggled event, + Emitter emit, + ) async { + emit( + state.copyWith(isOn: event.isOn), + ); + + if (!state.isConnected) { + return; + } + + final connectionData = state.connectionData; + + if (connectionData == null) { + return; + } + + try { + if (event.isOn) { + await _controlRepository.turnOn( + address: connectionData.address, + port: connectionData.port, + ); + } else { + await _controlRepository.turnOff( + address: connectionData.address, + port: connectionData.port, + ); + } + } catch (e, t) { + addError(e, t); + } + } +} diff --git a/lib/control/bloc/control_event.dart b/lib/control/bloc/control_event.dart new file mode 100644 index 0000000..ebe8cb4 --- /dev/null +++ b/lib/control/bloc/control_event.dart @@ -0,0 +1,95 @@ +part of 'control_bloc.dart'; + +abstract class ControlEvent extends Equatable { + const ControlEvent(); + + @override + List get props => []; +} + +class ConnectionStateUpdated extends ControlEvent { + const ConnectionStateUpdated({ + required this.isConnected, + required this.connectionData, + }) : assert( + (isConnected && connectionData != null) || !isConnected, + 'connectionData must not be null when isConnected', + ); + + final bool isConnected; + + final ConnectionData? connectionData; + + @override + List get props => [isConnected, connectionData]; +} + +class LampMessageReceived extends ControlEvent { + const LampMessageReceived({ + required this.message, + }); + + final GyverLampMessage message; + + @override + List get props => [message]; +} + +class ControlRequested extends ControlEvent { + const ControlRequested(); +} + +class ModeUpdated extends ControlEvent { + const ModeUpdated({ + required this.mode, + }); + + final GyverLampMode mode; + + @override + List get props => [mode]; +} + +class BrightnessUpdated extends ControlEvent { + const BrightnessUpdated({ + required this.brightness, + }); + + final int brightness; + + @override + List get props => [brightness]; +} + +class SpeedUpdated extends ControlEvent { + const SpeedUpdated({ + required this.speed, + }); + + final int speed; + + @override + List get props => [speed]; +} + +class ScaleUpdated extends ControlEvent { + const ScaleUpdated({ + required this.scale, + }); + + final int scale; + + @override + List get props => [scale]; +} + +class PowerToggled extends ControlEvent { + const PowerToggled({ + required this.isOn, + }); + + final bool isOn; + + @override + List get props => [isOn]; +} diff --git a/lib/control/bloc/control_state.dart b/lib/control/bloc/control_state.dart new file mode 100644 index 0000000..eb00039 --- /dev/null +++ b/lib/control/bloc/control_state.dart @@ -0,0 +1,76 @@ +part of 'control_bloc.dart'; + +class ControlState extends Equatable { + const ControlState({ + required this.isConnected, + required this.connectionData, + required this.mode, + required this.brightness, + required this.speed, + required this.scale, + required this.isOn, + }) : assert( + (isConnected && connectionData != null) || !isConnected, + 'connectionData must not be null when isConnected', + ); + + final bool isConnected; + + final ConnectionData? connectionData; + + final GyverLampMode mode; + + final int brightness; + + final int speed; + + final int scale; + + final bool isOn; + + ControlState copyWith({ + bool? isConnected, + ConnectionData? connectionData, + GyverLampMode? mode, + int? brightness, + int? speed, + int? scale, + bool? isOn, + }) { + return ControlState( + isConnected: isConnected ?? this.isConnected, + connectionData: connectionData ?? this.connectionData, + mode: mode ?? this.mode, + brightness: brightness ?? this.brightness, + speed: speed ?? this.speed, + scale: scale ?? this.scale, + isOn: isOn ?? this.isOn, + ); + } + + ControlState copyWithConnectionState({ + required bool isConnected, + required ConnectionData? connectionData, + }) { + return ControlState( + isConnected: isConnected, + connectionData: connectionData, + mode: mode, + brightness: brightness, + speed: speed, + scale: scale, + isOn: isOn, + ); + } + + @override + List get props => [ + isConnected, + connectionData, + mode, + brightness, + speed, + scale, + isOn, + ]; +} diff --git a/lib/control/control.dart b/lib/control/control.dart new file mode 100644 index 0000000..5767490 --- /dev/null +++ b/lib/control/control.dart @@ -0,0 +1,3 @@ +export 'bloc/control_bloc.dart'; +export 'view/view.dart'; +export 'widgets/widgets.dart'; diff --git a/lib/control/view/control_page.dart b/lib/control/view/control_page.dart new file mode 100644 index 0000000..c130822 --- /dev/null +++ b/lib/control/view/control_page.dart @@ -0,0 +1,47 @@ +import 'package:control_repository/control_repository.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class ControlPage extends StatelessWidget { + const ControlPage({super.key}); + + static Route route() { + return GyverLampPageRoute( + builder: (_) => const ControlPage(), + settings: const RouteSettings(name: 'control'), + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + lazy: false, + create: (context) { + final connectionState = context.read().state; + + return ControlBloc( + controlRepository: context.read(), + isConnected: connectionState.isConnected, + connectionData: connectionState.connectionData, + )..add( + const ControlRequested(), + ); + }, + child: BlocListener( + listenWhen: (p, c) => p.isConnected != c.isConnected, + listener: (context, state) { + context.read().add( + ConnectionStateUpdated( + isConnected: state.isConnected, + connectionData: state.connectionData, + ), + ); + }, + child: const ControlView(), + ), + ); + } +} diff --git a/lib/control/view/control_view.dart b/lib/control/view/control_view.dart new file mode 100644 index 0000000..11ba454 --- /dev/null +++ b/lib/control/view/control_view.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class ControlView extends StatelessWidget { + const ControlView({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return BlocListener( + listenWhen: (p, c) => p.isOn != c.isOn, + listener: (context, state) { + if (!state.isConnected) { + return; + } + + AlertMessenger.of(context).showInfo( + message: state.isOn ? l10n.lampIsOn : l10n.lampIsOff, + ); + }, + child: const Scaffold( + appBar: ControlAppBar(), + body: Center( + child: Effect(), + ), + bottomNavigationBar: Padding( + padding: EdgeInsets.all(GyverLampSpacings.xlgsm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ModePicker(), + GyverLampGaps.xlg, + ControlRulers(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/control/view/view.dart b/lib/control/view/view.dart new file mode 100644 index 0000000..5e99606 --- /dev/null +++ b/lib/control/view/view.dart @@ -0,0 +1,2 @@ +export 'control_page.dart'; +export 'control_view.dart'; diff --git a/lib/control/widgets/app_bar.dart b/lib/control/widgets/app_bar.dart new file mode 100644 index 0000000..5424c0b --- /dev/null +++ b/lib/control/widgets/app_bar.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/settings/view/view.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class ControlAppBar extends StatelessWidget implements PreferredSizeWidget { + const ControlAppBar({super.key}); + + @override + Size get preferredSize => kCustomAppBarSize; + + @override + Widget build(BuildContext context) { + return CustomAppBar( + padding: const EdgeInsets.symmetric( + horizontal: GyverLampSpacings.xlgsm, + ), + leading: const ConnectionStatusIndicator(), + actions: [ + FlatIconButton.medium( + icon: GyverLampIcons.settings, + onPressed: () { + Navigator.of(context).push( + SettingsPage.route(), + ); + }, + ), + BlocBuilder( + buildWhen: (p, c) => p.isOn != c.isOn, + builder: (context, state) { + return Switcher( + value: state.isOn, + onChanged: (isOn) { + context.read().add( + PowerToggled(isOn: isOn), + ); + }, + ); + }, + ), + ], + ); + } +} diff --git a/lib/control/widgets/effect.dart b/lib/control/widgets/effect.dart new file mode 100644 index 0000000..e835177 --- /dev/null +++ b/lib/control/widgets/effect.dart @@ -0,0 +1,72 @@ +import 'package:control_repository/control_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp_effects/gyver_lamp_effects.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class Effect extends StatefulWidget { + const Effect({super.key}); + + @override + State createState() => _EffectState(); +} + +class _EffectState extends State { + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 400, + maxWidth: 400, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: GyverLampSpacings.xxlg * 2, + vertical: GyverLampSpacings.xxlg, + ), + child: AspectRatio( + aspectRatio: 1, + child: BlocBuilder( + buildWhen: (p, c) { + return p.mode != c.mode || + p.speed != c.speed || + p.scale != c.scale; + }, + builder: (context, state) { + final type = switch (state.mode) { + GyverLampMode.sparkles => GyverLampEffectType.sparkles, + GyverLampMode.fire => GyverLampEffectType.fire, + GyverLampMode.rainbowVertical => + GyverLampEffectType.verticalRainbow, + GyverLampMode.rainbowHorizontal => + GyverLampEffectType.horizontalRainbow, + GyverLampMode.colors => GyverLampEffectType.colors, + GyverLampMode.madness => GyverLampEffectType.madness, + GyverLampMode.cloud => GyverLampEffectType.clouds, + GyverLampMode.lava => GyverLampEffectType.lava, + GyverLampMode.plasma => GyverLampEffectType.plasma, + GyverLampMode.rainbow => GyverLampEffectType.rainbow, + GyverLampMode.rainbowStripes => + GyverLampEffectType.rainbowStripes, + GyverLampMode.zebra => GyverLampEffectType.zebra, + GyverLampMode.forest => GyverLampEffectType.forest, + GyverLampMode.ocean => GyverLampEffectType.ocean, + GyverLampMode.color => GyverLampEffectType.color, + GyverLampMode.snow => GyverLampEffectType.snow, + GyverLampMode.matrix => GyverLampEffectType.matrix, + GyverLampMode.fireflies => GyverLampEffectType.fireflies, + }; + + return GyverLampEffect( + type: type, + speed: state.speed, + scale: state.scale, + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/control/widgets/mode_picker.dart b/lib/control/widgets/mode_picker.dart new file mode 100644 index 0000000..80f1b4d --- /dev/null +++ b/lib/control/widgets/mode_picker.dart @@ -0,0 +1,52 @@ +import 'package:control_repository/control_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class ModePicker extends StatelessWidget { + const ModePicker({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return BlocBuilder( + buildWhen: (p, c) => p.mode != c.mode, + builder: (context, state) { + return CustomDropdownButton( + items: GyverLampMode.values.map((mode) { + return CustomDropdownMenuItem( + value: mode, + label: switch (mode) { + GyverLampMode.sparkles => l10n.sparklesMode, + GyverLampMode.fire => l10n.fireMode, + GyverLampMode.rainbowVertical => l10n.rainbowVerticalMode, + GyverLampMode.rainbowHorizontal => l10n.rainbowHorizontalMode, + GyverLampMode.colors => l10n.colorsMode, + GyverLampMode.madness => l10n.madnessMode, + GyverLampMode.cloud => l10n.cloudsMode, + GyverLampMode.lava => l10n.lavaMode, + GyverLampMode.plasma => l10n.plasmaMode, + GyverLampMode.rainbow => l10n.rainbowMode, + GyverLampMode.rainbowStripes => l10n.rainbowStripesMode, + GyverLampMode.zebra => l10n.zebraMode, + GyverLampMode.forest => l10n.forestMode, + GyverLampMode.ocean => l10n.oceanMode, + GyverLampMode.color => l10n.colorMode, + GyverLampMode.snow => l10n.snowMode, + GyverLampMode.matrix => l10n.matrixMode, + GyverLampMode.fireflies => l10n.firefliesMode, + }, + ); + }).toList(), + selected: state.mode, + onChanged: (mode) { + context.read().add(ModeUpdated(mode: mode)); + }, + ); + }, + ); + } +} diff --git a/lib/control/widgets/rulers.dart b/lib/control/widgets/rulers.dart new file mode 100644 index 0000000..514f945 --- /dev/null +++ b/lib/control/widgets/rulers.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class ControlRulers extends StatelessWidget { + const ControlRulers({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return SafeArea( + top: false, + left: false, + right: false, + child: Row( + children: [ + Column( + children: [ + _RulerName( + icon: GyverLampIcons.sun, + label: l10n.brightness, + ), + GyverLampGaps.xlgsm, + _RulerName( + icon: GyverLampIcons.speed, + label: l10n.speed, + ), + GyverLampGaps.xlgsm, + _RulerName( + icon: GyverLampIcons.scale, + label: l10n.scale, + ), + ], + ), + GyverLampGaps.xlgsm, + Expanded( + child: Column( + children: [ + BlocBuilder( + buildWhen: (p, c) => p.brightness != c.brightness, + builder: (context, state) { + return Ruler( + value: state.brightness, + maxValue: 255, + onChanged: (brightness) { + context + .read() + .add(BrightnessUpdated(brightness: brightness)); + }, + ); + }, + ), + GyverLampGaps.xlgsm, + BlocBuilder( + buildWhen: (p, c) => p.speed != c.speed, + builder: (context, state) { + return Ruler( + value: state.speed, + maxValue: 255, + onChanged: (speed) { + context + .read() + .add(SpeedUpdated(speed: speed)); + }, + ); + }, + ), + GyverLampGaps.xlgsm, + BlocBuilder( + buildWhen: (p, c) => p.scale != c.scale, + builder: (context, state) { + return Ruler( + value: state.scale, + maxValue: 255, + onChanged: (scale) { + context + .read() + .add(ScaleUpdated(scale: scale)); + }, + ); + }, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _RulerName extends StatelessWidget { + const _RulerName({ + required this.icon, + required this.label, + }); + + final IconData icon; + + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return SizedBox( + height: 48, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 24, + color: theme.onBackground, + ), + Text( + label, + style: GyverLampTextStyles.body2.copyWith( + color: theme.onBackground, + ), + ), + ], + ), + ); + } +} diff --git a/lib/control/widgets/widgets.dart b/lib/control/widgets/widgets.dart new file mode 100644 index 0000000..625ea31 --- /dev/null +++ b/lib/control/widgets/widgets.dart @@ -0,0 +1,4 @@ +export 'app_bar.dart'; +export 'effect.dart'; +export 'mode_picker.dart'; +export 'rulers.dart'; diff --git a/lib/initial_setup/initial_setup.dart b/lib/initial_setup/initial_setup.dart new file mode 100644 index 0000000..3b46d13 --- /dev/null +++ b/lib/initial_setup/initial_setup.dart @@ -0,0 +1,2 @@ +export 'view/view.dart'; +export 'widgets/widgets.dart'; diff --git a/lib/initial_setup/view/initial_setup_page.dart b/lib/initial_setup/view/initial_setup_page.dart new file mode 100644 index 0000000..4055cf0 --- /dev/null +++ b/lib/initial_setup/view/initial_setup_page.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp/initial_setup/view/view.dart'; + +class InitialSetupPage extends StatelessWidget { + const InitialSetupPage({super.key}); + + @override + Widget build(BuildContext context) { + return const InitialSetupView(); + } +} diff --git a/lib/initial_setup/view/initial_setup_view.dart b/lib/initial_setup/view/initial_setup_view.dart new file mode 100644 index 0000000..8b7fb45 --- /dev/null +++ b/lib/initial_setup/view/initial_setup_view.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/initial_setup/initial_setup.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class InitialSetupView extends StatelessWidget { + const InitialSetupView({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return BlocListener( + listenWhen: (p, c) => c is ConnectionSuccess || c is ConnectionFailure, + listener: (context, state) { + switch (state) { + case ConnectionSuccess(): + _goToControlPage(context); + + case ConnectionFailure(): + AlertMessenger.of(context).showError( + message: l10n.connectionFailed, + ); + + default: + // Ignore. + } + }, + child: Scaffold( + appBar: CustomAppBar( + actions: [ + BlocBuilder( + buildWhen: (p, c) => p.isConnecting || c.isConnecting, + builder: (context, state) { + return FlatTextButton.medium( + onPressed: state.isConnecting + ? null + : () => _goToControlPage(context), + child: Text(context.l10n.skip), + ); + }, + ), + ], + ), + body: const InitialSetupForm(), + bottomNavigationBar: const InitialSetupBottomBar(), + ), + ); + } + + void _goToControlPage(BuildContext context) { + // Clear all the alerts. + AlertMessenger.of(context).clear(); + + // Mark initial setup as completed, so on the next launch user will start + // from the control page. + context + .read() + .setInitialSetupCompleted(completed: true); + + // Resetting connection data if it is not valid. + context.read().add( + const ConnectionDataCheckRequested(), + ); + + Navigator.of(context).pushReplacement( + ControlPage.route(), + ); + } +} diff --git a/lib/initial_setup/view/view.dart b/lib/initial_setup/view/view.dart new file mode 100644 index 0000000..b0a4a23 --- /dev/null +++ b/lib/initial_setup/view/view.dart @@ -0,0 +1,2 @@ +export 'initial_setup_page.dart'; +export 'initial_setup_view.dart'; diff --git a/lib/initial_setup/widgets/bottom_bar.dart b/lib/initial_setup/widgets/bottom_bar.dart new file mode 100644 index 0000000..aee32b8 --- /dev/null +++ b/lib/initial_setup/widgets/bottom_bar.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class InitialSetupBottomBar extends StatelessWidget { + const InitialSetupBottomBar({super.key}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: const Padding( + padding: EdgeInsets.only( + left: GyverLampSpacings.xlgsm, + right: GyverLampSpacings.xlgsm, + top: GyverLampSpacings.sm, + bottom: GyverLampSpacings.lg, + ), + child: Row( + children: [ + Expanded( + child: ConnectButton.large(), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/initial_setup/widgets/form.dart b/lib/initial_setup/widgets/form.dart new file mode 100644 index 0000000..cfa1d8c --- /dev/null +++ b/lib/initial_setup/widgets/form.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class InitialSetupForm extends StatelessWidget { + const InitialSetupForm({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context).extension()!; + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(GyverLampSpacings.xlgsm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.initialSetupPageTitle, + style: GyverLampTextStyles.headline5Bold.copyWith( + color: theme.onBackground, + ), + ), + GyverLampGaps.sm, + Text( + l10n.initialSetupFormDescription, + style: GyverLampTextStyles.body2.copyWith( + color: theme.textSecondary, + ), + ), + GyverLampGaps.xlg, + const IpAddressField(), + GyverLampGaps.lg, + const PortField(), + ], + ), + ), + ); + } +} diff --git a/lib/initial_setup/widgets/widgets.dart b/lib/initial_setup/widgets/widgets.dart new file mode 100644 index 0000000..aae5262 --- /dev/null +++ b/lib/initial_setup/widgets/widgets.dart @@ -0,0 +1,2 @@ +export 'bottom_bar.dart'; +export 'form.dart'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb new file mode 100644 index 0000000..7ffc331 --- /dev/null +++ b/lib/l10n/arb/app_en.arb @@ -0,0 +1,219 @@ +{ + "@@locale": "en", + "connect": "Connect", + "@connect": { + "description": "Label connect" + }, + "skip": "Skip", + "@skip": { + "description": "Label skip" + }, + "initialSetupPageTitle": "Add Your First Lamp", + "@initialSetupPageTitle": { + "description": "Title on the initial setup page" + }, + "initialSetupFormDescription": "Fill in the details of your lamp to easily control it through the app. You can also do it later.", + "@initialSetupFormDescription": { + "description": "Text shown on top of the initial setup form" + }, + "ip": "IP", + "@ip": { + "description": "Label IP" + }, + "ipErrorHint": "Wrong IP format. Example: 192.168.0.1", + "@ipErrorHint": { + "description": "Error hint shown under IP field after wrong input" + }, + "port": "Port", + "@port": { + "description": "Label port" + }, + "portErrorHint": "Wrong port format. Example: 8888", + "@portErrorHint": { + "description": "Error hint shown under Port field after wrong input" + }, + "notConnected": "Not Connected", + "@notConnected": { + "description": "Label not connected" + }, + "connected": "Connected", + "@connected": { + "description": "Label connected" + }, + "connecting": "Connecting", + "@connecting": { + "description": "Label connecting" + }, + "brightness": "Brightness", + "@brightness": { + "description": "Label brightness" + }, + "speed": "Speed", + "@speed": { + "description": "Label speed" + }, + "scale": "Scale", + "@scale": { + "description": "Label scale" + }, + "settings": "Settings", + "@settings": { + "description": "Label settings" + }, + "general": "General", + "@general": { + "description": "Label general settings" + }, + "language": "Language", + "@language": { + "description": "Label language" + }, + "darkMode": "Dark Mode", + "@darkMode": { + "description": "Label dark mode" + }, + "getInTouch": "Get in Touch", + "@getInTouch": { + "description": "Label get in touch settings" + }, + "email": "Email", + "@email": { + "description": "Label email" + }, + "twitter": "Twitter", + "@twitter": { + "description": "Label twitter" + }, + "dribbble": "Dribbble", + "@dribbble": { + "description": "Label dribbble" + }, + "otherStuff": "Other Stuff", + "@otherStuff": { + "description": "Label other stuff settings" + }, + "lampProject": "Lamp Project", + "@lampProject": { + "description": "Label lamp project" + }, + "credits": "Credits", + "@credits": { + "description": "Label credits" + }, + "privacyPolicy": "Privacy Policy", + "@privacyPolicy": { + "description": "Label privacy policy" + }, + "termsOfUse": "Terms Of Use", + "@termsOfUse": { + "description": "Label terms of use" + }, + "wrongFormat": "Wrong format", + "@wrongFormat": { + "description": "Label wrong format" + }, + "connectDialogTitle": "Connect lamp", + "@connectDialogTitle": { + "description": "Title text in connect dialog" + }, + "disconnectDialogTitle": "Disconnect from lamp", + "@disconnectDialogTitle": { + "description": "Title text in disconnect dialog" + }, + "disconnectDialogBody": "Are you sure you want to disconnect the phone from the lamp?", + "@disconnectDialogBody": { + "description": "Body text in disconnect dialog" + }, + "cancel": "Cancel", + "@cancel": { + "description": "Label cancel" + }, + "disconnect": "Disconnect", + "@disconnect": { + "description": "Label disconnect" + }, + "sparklesMode": "Sparkles", + "@sparklesMode": { + "description": "Label for the sparkles mode" + }, + "fireMode": "Fire", + "@fireMode": { + "description": "Label for the fire mode" + }, + "rainbowVerticalMode": "Rainbow Vertical", + "@rainbowVerticalMode": { + "description": "Label for the rainbow vertical mode" + }, + "rainbowHorizontalMode": "Rainbow Horizontal", + "@rainbowHorizontalMode": { + "description": "Label for the rainbow horizontal mode" + }, + "colorsMode": "Colors", + "@colorsMode": { + "description": "Label for the colors mode" + }, + "madnessMode": "Madness", + "@madnessMode": { + "description": "Label for the madness mode" + }, + "cloudsMode": "Clouds", + "@cloudsMode": { + "description": "Label for the clouds mode" + }, + "lavaMode": "Lava", + "@lavaMode": { + "description": "Label for the lava mode" + }, + "plasmaMode": "Plasma", + "@plasmaMode": { + "description": "Label for the plasma mode" + }, + "rainbowMode": "Rainbow", + "@rainbowMode": { + "description": "Label for the rainbow mode" + }, + "rainbowStripesMode": "Rainbow Stripes", + "@rainbowStripesMode": { + "description": "Label for the rainbow stripes mode" + }, + "zebraMode": "Zebra", + "@zebraMode": { + "description": "Label for the zebra mode" + }, + "forestMode": "Forest", + "@forestMode": { + "description": "Label for the forest mode" + }, + "oceanMode": "Ocean", + "@oceanMode": { + "description": "Label for the ocean mode" + }, + "colorMode": "Color", + "@colorMode": { + "description": "Label for the color mode" + }, + "snowMode": "Snow", + "@snowMode": { + "description": "Label for the snow mode" + }, + "matrixMode": "Matrix", + "@matrixMode": { + "description": "Label for the matrix mode" + }, + "firefliesMode": "Fireflies", + "@firefliesMode": { + "description": "Label for the fireflies mode" + }, + "connectionFailed": "Error while connecting to the lamp. Please check IP and Port and try again.", + "@connectionFailed": { + "description": "Error text shown after unsuccessful connection attempt" + }, + "lampIsOn": "Lamp is ON.", + "@lampIsOn": { + "description": "Text shown after lamp is toggled on" + }, + "lampIsOff": "Lamp is OFF.", + "@lampIsOff": { + "description": "Text shown after lamp is toggled off" + } +} \ No newline at end of file diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb new file mode 100644 index 0000000..784b133 --- /dev/null +++ b/lib/l10n/arb/app_ru.arb @@ -0,0 +1,57 @@ +{ + "@@locale": "ru", + "connect": "Подключиться", + "skip": "Пропустить", + "initialSetupPageTitle": "Добавте свою первую лампу", + "initialSetupFormDescription": "Заполните детали своей лампы чтобы иметь возможность контролировать ее при помощи приложения. Вы можете сделать это позже.", + "ip": "IP", + "ipErrorHint": "Неверный формат IP. Пример: 192.168.0.1", + "port": "Порт", + "portErrorHint": "Неверный формат порта. Пример: 8888", + "notConnected": "Не подключена", + "connected": "Подключена", + "connecting": "Подключение", + "brightness": "Яркость", + "speed": "Скорость", + "scale": "Масштаб", + "settings": "Настройки", + "general": "Основное", + "language": "Язык", + "darkMode": "Темный режим", + "getInTouch": "Обратная связь", + "email": "Email", + "twitter": "Twitter", + "dribbble": "Dribbble", + "otherStuff": "Другое", + "lampProject": "Проект лампы", + "credits": "Авторы", + "privacyPolicy": "Политика конфиденциальности", + "termsOfUse": "Условия использования", + "wrongFormat": "Неверный формат", + "connectDialogTitle": "Подключить лампу", + "disconnectDialogTitle": "Отключиться от лымпы", + "disconnectDialogBody": "Вы уверены что хотите отключить телефон от лымпы?", + "cancel": "Закрыть", + "disconnect": "Отключиться", + "sparklesMode": "Конфетти", + "fireMode": "Огонь", + "rainbowVerticalMode": "Радуга вертикальная", + "rainbowHorizontalMode": "Радуга горизонтальная", + "colorsMode": "Цвета", + "madnessMode": "Безумие", + "cloudsMode": "Облака", + "lavaMode": "Лава", + "plasmaMode": "Плазма", + "rainbowMode": "Радуга", + "rainbowStripesMode": "Разужные полоски", + "zebraMode": "Зебра", + "forestMode": "Лес", + "oceanMode": "Океан", + "colorMode": "Цвет", + "snowMode": "Снег", + "matrixMode": "Матрица", + "firefliesMode": "Светляки", + "connectionFailed": "Произошла ошибка при попытке подключения к лампе. Пожалуйста проверьте правильность IP адреса и порта, и попробуйте подключиться опять.", + "lampIsOn": "Лампа включена.", + "lampIsOff": "Лампа выключена." +} \ No newline at end of file diff --git a/lib/l10n/arb/app_uk.arb b/lib/l10n/arb/app_uk.arb new file mode 100644 index 0000000..cfa0713 --- /dev/null +++ b/lib/l10n/arb/app_uk.arb @@ -0,0 +1,57 @@ +{ + "@@locale": "uk", + "connect": "Підключитися", + "skip": "Пропустити", + "initialSetupPageTitle": "Додайте свою першу лампу", + "initialSetupFormDescription": "Заповніть деталі своєї лампи, щоб мати змогу контролювати її за допомогою застосунку. Ви можете зробити це пізніше.", + "ip": "IP", + "ipErrorHint": "Невірний формат IP. Приклад: 192.168.0.1", + "port": "Порт", + "portErrorHint": "Невірний формат порта. Приклад: 8888", + "notConnected": "Не підключена", + "connected": "Підключена", + "connecting": "Підключення", + "brightness": "Яскравість", + "speed": "Швидкість", + "scale": "Масштаб", + "settings": "Налаштування", + "general": "Основні", + "language": "Мова", + "darkMode": "Темний режим", + "getInTouch": "Зворотній зв'язок", + "email": "Email", + "twitter": "Twitter", + "dribbble": "Dribbble", + "otherStuff": "Інше", + "lampProject": "Проект лампи", + "credits": "Автори", + "privacyPolicy": "Політика конфіденційності", + "termsOfUse": "Умови користування", + "wrongFormat": "Невірний формат", + "connectDialogTitle": "Подключити лампу", + "disconnectDialogTitle": "Від'єднатися від лампи", + "disconnectDialogBody": "Ви впевнені, що хочете від'єднати телефон від лампи?", + "cancel": "Закрити", + "disconnect": "Від'єднатися", + "sparklesMode": "Конфетті", + "fireMode": "Вогонь", + "rainbowVerticalMode": "Веселка вертикальна", + "rainbowHorizontalMode": "Веселка горизонтальна", + "colorsMode": "Кольори", + "madnessMode": "Божевілля", + "cloudsMode": "Хмари", + "lavaMode": "Лава", + "plasmaMode": "Плазма", + "rainbowMode": "Веселка", + "rainbowStripesMode": "Райдужні смужки", + "zebraMode": "Зебра", + "forestMode": "Ліс", + "oceanMode": "Океан", + "colorMode": "Колір", + "snowMode": "Сніг", + "matrixMode": "Матриця", + "firefliesMode": "Світляки", + "connectionFailed": "Виникла помилка при спробі приєднатися до лампи. Будь-ласка перевірти правильність IP адреси і порту, та спробуйте підключитися знову.", + "lampIsOn": "Лампу увімкнено.", + "lampIsOff": "Лампу вимкнено." +} \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart new file mode 100644 index 0000000..17c891b --- /dev/null +++ b/lib/l10n/l10n.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +export 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +extension AppLocalizationsX on BuildContext { + AppLocalizations get l10n => AppLocalizations.of(this); +} diff --git a/lib/main_development.dart b/lib/main_development.dart new file mode 100644 index 0000000..157ab83 --- /dev/null +++ b/lib/main_development.dart @@ -0,0 +1,56 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:connection_repository/connection_repository.dart'; +import 'package:control_repository/control_repository.dart'; +import 'package:gyver_lamp/app/app.dart'; +import 'package:gyver_lamp/bootstrap.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp_client/gyver_lamp_client.dart'; +import 'package:settings_controller/settings_controller.dart'; + +Future main() async { + unawaited( + bootstrap(() { + return AppLoader( + dataLoader: () async { + final client = GyverLampClient( + onSend: (address, port, request) { + log('client.onSend($request, $address:$port)'); + }, + onResponse: (address, port, response) { + log('client.onResponse($response, $address:$port)'); + }, + onError: (address, port, error) { + log('client.onError($error, $address:$port)'); + }, + ); + + final settingsController = SettingsController( + persistence: InMemorySettingsPersistence(), + ); + + await settingsController.loadStateFromPersistence(); + + final initialSetupCompleted = + settingsController.initialSetupCompleted.value ?? false; + final address = settingsController.ipAddress.value; + final port = settingsController.port.value; + + final initialConnectionData = + !initialSetupCompleted || address == null || port == null + ? null + : ConnectionData(address: address, port: port); + + return AppData( + connectionRepository: ConnectionRepository(client: client), + controlRepository: ControlRepository(client: client), + settingsController: settingsController, + initialConnectionData: initialConnectionData, + initialSetupCompleted: initialSetupCompleted, + ); + }, + ); + }), + ); +} diff --git a/lib/main_production.dart b/lib/main_production.dart new file mode 100644 index 0000000..b73fdad --- /dev/null +++ b/lib/main_production.dart @@ -0,0 +1,56 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:connection_repository/connection_repository.dart'; +import 'package:control_repository/control_repository.dart'; +import 'package:gyver_lamp/app/app.dart'; +import 'package:gyver_lamp/bootstrap.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp_client/gyver_lamp_client.dart'; +import 'package:settings_controller/settings_controller.dart'; + +Future main() async { + unawaited( + bootstrap(() { + return AppLoader( + dataLoader: () async { + final client = GyverLampClient( + onSend: (address, port, request) { + log('client.onSend($request, $address:$port)'); + }, + onResponse: (address, port, response) { + log('client.onResponse($response, $address:$port)'); + }, + onError: (address, port, error) { + log('client.onError($error, $address:$port)'); + }, + ); + + final settingsController = SettingsController( + persistence: LocalStorageSettingsPersistence(), + ); + + await settingsController.loadStateFromPersistence(); + + final initialSetupCompleted = + settingsController.initialSetupCompleted.value ?? false; + final address = settingsController.ipAddress.value; + final port = settingsController.port.value; + + final initialConnectionData = + !initialSetupCompleted || address == null || port == null + ? null + : ConnectionData(address: address, port: port); + + return AppData( + connectionRepository: ConnectionRepository(client: client), + controlRepository: ControlRepository(client: client), + settingsController: settingsController, + initialConnectionData: initialConnectionData, + initialSetupCompleted: initialSetupCompleted, + ); + }, + ); + }), + ); +} diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart new file mode 100644 index 0000000..3b46d13 --- /dev/null +++ b/lib/settings/settings.dart @@ -0,0 +1,2 @@ +export 'view/view.dart'; +export 'widgets/widgets.dart'; diff --git a/lib/settings/view/settings_page.dart b/lib/settings/view/settings_page.dart new file mode 100644 index 0000000..34a7010 --- /dev/null +++ b/lib/settings/view/settings_page.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp/settings/settings.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + static Route route() { + return GyverLampPageRoute( + builder: (_) => const SettingsPage(), + settings: const RouteSettings(name: 'settings'), + ); + } + + @override + Widget build(BuildContext context) { + return const SettingsView(); + } +} diff --git a/lib/settings/view/settings_view.dart b/lib/settings/view/settings_view.dart new file mode 100644 index 0000000..1675469 --- /dev/null +++ b/lib/settings/view/settings_view.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp/settings/settings.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class SettingsView extends StatelessWidget { + const SettingsView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CustomAppBar( + padding: const EdgeInsets.symmetric( + horizontal: GyverLampSpacings.xlgsm, + ), + leading: FlatIconButton.medium( + icon: GyverLampIcons.arrow_left, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: context.l10n.settings, + ), + body: const SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: GyverLampSpacings.xlgsm, + ), + child: SafeArea( + child: Column( + children: [ + GeneralSettings(), + GyverLampGaps.lg, + GetInTouchSettings(), + GyverLampGaps.lg, + OtherSettings(), + GyverLampGaps.lg, + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/settings/view/view.dart b/lib/settings/view/view.dart new file mode 100644 index 0000000..0caee92 --- /dev/null +++ b/lib/settings/view/view.dart @@ -0,0 +1,2 @@ +export 'settings_page.dart'; +export 'settings_view.dart'; diff --git a/lib/settings/widgets/dark_mode_switcher.dart b/lib/settings/widgets/dark_mode_switcher.dart new file mode 100644 index 0000000..e6695c1 --- /dev/null +++ b/lib/settings/widgets/dark_mode_switcher.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class DarkModeSwitcher extends StatelessWidget { + const DarkModeSwitcher({super.key}); + + @override + Widget build(BuildContext context) { + final controller = context.read(); + + return ValueListenableBuilder( + valueListenable: controller.darkModeOn, + builder: (context, active, _) { + return Switcher( + value: active ?? false, + onChanged: (active) { + controller.setDarkModeOn(active: active); + }, + ); + }, + ); + } +} diff --git a/lib/settings/widgets/general_settings.dart b/lib/settings/widgets/general_settings.dart new file mode 100644 index 0000000..6227fcf --- /dev/null +++ b/lib/settings/widgets/general_settings.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp/settings/settings.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class GeneralSettings extends StatelessWidget { + const GeneralSettings({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return SettingTileGroup( + label: l10n.general, + tiles: [ + SettingTile( + icon: GyverLampIcons.language, + label: l10n.language, + action: const LanguageSelector(), + ), + SettingTile( + icon: GyverLampIcons.moon, + label: l10n.darkMode, + action: const DarkModeSwitcher(), + ), + ], + ); + } +} diff --git a/lib/settings/widgets/get_in_touch_settings.dart b/lib/settings/widgets/get_in_touch_settings.dart new file mode 100644 index 0000000..b023c72 --- /dev/null +++ b/lib/settings/widgets/get_in_touch_settings.dart @@ -0,0 +1,60 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class GetInTouchSettings extends StatelessWidget { + const GetInTouchSettings({ + super.key, + this.urlLauncher = launchUrlString, + }); + + final AsyncValueSetter urlLauncher; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Column( + children: [ + SettingTileGroup( + label: l10n.getInTouch, + tiles: [ + SettingTile( + icon: GyverLampIcons.mail, + label: l10n.email, + action: FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: () { + urlLauncher('mailto:sokolovskyi.konstantin@gmail.com'); + }, + ), + ), + SettingTile( + icon: GyverLampIcons.x, + label: l10n.twitter, + action: FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: () { + urlLauncher('https://twitter.com/k_sokolovskyi'); + }, + ), + ), + SettingTile( + icon: GyverLampIcons.dribbble, + label: l10n.dribbble, + action: FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: () { + urlLauncher('https://dribbble.com/ira_dehtiar'); + }, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/settings/widgets/language_selector.dart b/lib/settings/widgets/language_selector.dart new file mode 100644 index 0000000..d08c370 --- /dev/null +++ b/lib/settings/widgets/language_selector.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class LanguageSelector extends StatefulWidget { + const LanguageSelector({super.key}); + + @override + State createState() => _LanguageSelectorState(); +} + +class _LanguageSelectorState extends State { + static final _segments = AppLocalizations.supportedLocales + .map( + (l) => SelectorSegment( + value: l, + label: l.languageCode.toUpperCase(), + ), + ) + .toList(); + + @override + Widget build(BuildContext context) { + final controller = context.read(); + + return ValueListenableBuilder( + valueListenable: controller.locale, + builder: (context, savedLocale, _) { + final contextLocale = savedLocale ?? Localizations.localeOf(context); + + final Locale selectedLocale; + + if (AppLocalizations.supportedLocales.contains(contextLocale)) { + selectedLocale = contextLocale; + } else { + selectedLocale = AppLocalizations.supportedLocales.first; + } + + return SegmentedSelector( + selected: selectedLocale, + segments: _segments, + onChanged: (locale) { + controller.setLocale(locale: locale); + }, + ); + }, + ); + } +} diff --git a/lib/settings/widgets/other_settings.dart b/lib/settings/widgets/other_settings.dart new file mode 100644 index 0000000..9606d03 --- /dev/null +++ b/lib/settings/widgets/other_settings.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class OtherSettings extends StatelessWidget { + const OtherSettings({ + super.key, + this.urlLauncher = launchUrlString, + }); + + final AsyncValueSetter urlLauncher; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Column( + children: [ + SettingTileGroup( + label: l10n.otherStuff, + tiles: [ + SettingTile( + icon: GyverLampIcons.github, + label: l10n.lampProject, + action: FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: () { + urlLauncher('https://github.com/AlexGyver/GyverLamp'); + }, + ), + ), + SettingTile( + icon: GyverLampIcons.group, + label: l10n.credits, + action: const FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: null, + ), + ), + SettingTile( + icon: GyverLampIcons.policy, + label: l10n.privacyPolicy, + action: const FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: null, + ), + ), + SettingTile( + icon: GyverLampIcons.align_left, + label: l10n.termsOfUse, + action: const FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: null, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/settings/widgets/widgets.dart b/lib/settings/widgets/widgets.dart new file mode 100644 index 0000000..8f0dd47 --- /dev/null +++ b/lib/settings/widgets/widgets.dart @@ -0,0 +1,5 @@ +export 'dark_mode_switcher.dart'; +export 'general_settings.dart'; +export 'get_in_touch_settings.dart'; +export 'language_selector.dart'; +export 'other_settings.dart'; diff --git a/lib/splash/splash.dart b/lib/splash/splash.dart new file mode 100644 index 0000000..00ffcf9 --- /dev/null +++ b/lib/splash/splash.dart @@ -0,0 +1 @@ +export 'view/view.dart'; diff --git a/lib/splash/view/splash_page.dart b/lib/splash/view/splash_page.dart new file mode 100644 index 0000000..15ec109 --- /dev/null +++ b/lib/splash/view/splash_page.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:rive/rive.dart'; + +class SplashPage extends StatefulWidget { + const SplashPage({super.key}); + + static const kFadeDuration = Duration(milliseconds: 600); + static const kAnimationDuration = Duration(milliseconds: 2000); + + @override + State createState() => SplashPageState(); +} + +class SplashPageState extends State + with SingleTickerProviderStateMixin { + late final _controller = AnimationController( + vsync: this, + duration: SplashPage.kFadeDuration, + ); + + @visibleForTesting + AnimationController? get debugAnimationController { + AnimationController? controller; + + // ignore: prefer_asserts_with_message + assert(() { + controller = _controller; + return true; + }()); + + return controller; + } + + late final _animation = TweenSequence( + [ + TweenSequenceItem( + tween: CurveTween(curve: Curves.easeInQuad), + weight: 0.8, + ), + TweenSequenceItem( + tween: ConstantTween(1), + weight: 0.2, + ), + ], + ).animate(_controller); + + late final StateMachineController? _stateController; + + @override + void dispose() { + _controller.dispose(); + _stateController?.dispose(); + super.dispose(); + } + + void _animate(Artboard artboard) { + final stateController = StateMachineController.fromArtboard( + artboard, + 'machine', + )!; + + _stateController = stateController; + + artboard.addController(stateController); + + _controller + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + (stateController.findInput('connected')! as SMIBool) + .change(false); + } + }) + ..forward(); + } + + @override + Widget build(BuildContext context) { + const color = Color(0xFF142230); + + return AnnotatedRegion( + value: SystemUiOverlayStyle.light.copyWith( + systemNavigationBarColor: color, + systemNavigationBarIconBrightness: Brightness.light, + ), + child: ColoredBox( + color: color, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: AspectRatio( + aspectRatio: 1.6, + child: ColoredBox( + color: Colors.transparent, + child: FadeTransition( + opacity: _animation, + child: RiveAnimation.asset( + 'assets/switch.riv', + fit: BoxFit.fitWidth, + onInit: _animate, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/splash/view/view.dart b/lib/splash/view/view.dart new file mode 100644 index 0000000..7bc1c88 --- /dev/null +++ b/lib/splash/view/view.dart @@ -0,0 +1 @@ +export 'splash_page.dart'; diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..65861eb --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,18 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import rive_common +import shared_preferences_foundation +import tactile_feedback +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + RivePlugin.register(with: registry.registrar(forPlugin: "RivePlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + TactileFeedbackPlugin.register(with: registry.registrar(forPlugin: "TactileFeedbackPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..6be7238 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +source 'https://github.com/CocoaPods/Specs.git' + +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..b58a008 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,41 @@ +PODS: + - FlutterMacOS (1.0.0) + - rive_common (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - tactile_feedback (0.0.1): + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - rive_common (from `Flutter/ephemeral/.symlinks/plugins/rive_common/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - tactile_feedback (from `Flutter/ephemeral/.symlinks/plugins/tactile_feedback/macos`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + rive_common: + :path: Flutter/ephemeral/.symlinks/plugins/rive_common/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + tactile_feedback: + :path: Flutter/ephemeral/.symlinks/plugins/tactile_feedback/macos + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + rive_common: acedcab7802c0ece4b0d838b71d7deb637e1309a + shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + tactile_feedback: 1f7f76ae961b62210977f8dba73c625375326dd1 + url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 + +PODFILE CHECKSUM: 26a9ef6e22b1baf146cad35eb793f54e5e50126f + +COCOAPODS: 1.13.0 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4de0139 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,633 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 249666AE7299E3A143B6608A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 71AEA9C59E760F0B18CA1F2D /* Pods_Runner.framework */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* gyver_lamp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = gyver_lamp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 71AEA9C59E760F0B18CA1F2D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + AA82C6F07871D12F7FC6524D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + D5199A27DB01C6CF6640AD3E /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + E5ECFE5726B97C5FA9CB710D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 249666AE7299E3A143B6608A /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 3F577ABC2BF5765058834C6D /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* gyver_lamp.app */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 3F577ABC2BF5765058834C6D /* Pods */ = { + isa = PBXGroup; + children = ( + AA82C6F07871D12F7FC6524D /* Pods-Runner.debug.xcconfig */, + E5ECFE5726B97C5FA9CB710D /* Pods-Runner.release.xcconfig */, + D5199A27DB01C6CF6640AD3E /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 71AEA9C59E760F0B18CA1F2D /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 20A458FD8A5B528150F5C04E /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 7BC898E3133D5BE4EA98B5DE /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* gyver_lamp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 20A458FD8A5B528150F5C04E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 7BC898E3133D5BE4EA98B5DE /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..5432370 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..d53ef64 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..96d3fee --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "info": { + "version": 1, + "author": "xcode" + }, + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..d98d4ad Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..8f6ae41 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..db501c3 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..65cb29b Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..45ea0cb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..d08957e Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..29963c9 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..e6a08d5 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = gyver_lamp + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.gyverLamp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2023 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..08c3ab1 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..6da2918 --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleLocalizations + + en + ru + uk + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + LSApplicationQueriesSchemes + + https + mailto + + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..2722837 --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController.init() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..ee95ab7 --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/packages/connection_repository/.gitignore b/packages/connection_repository/.gitignore new file mode 100644 index 0000000..4fe5c69 --- /dev/null +++ b/packages/connection_repository/.gitignore @@ -0,0 +1,15 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock + +# 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/ + +# Coverage +coverage/ diff --git a/packages/connection_repository/README.md b/packages/connection_repository/README.md new file mode 100644 index 0000000..d11ccb3 --- /dev/null +++ b/packages/connection_repository/README.md @@ -0,0 +1,13 @@ +# Connection Repository + +[![coverage][coverage_badge]][coverage_badge] +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +Repository to manage the connection state. + +[coverage_badge]: https://raw.githubusercontent.com/VeryGoodOpenSource/very_good_cli/main/coverage_badge.svg +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT \ No newline at end of file diff --git a/packages/connection_repository/analysis_options.yaml b/packages/connection_repository/analysis_options.yaml new file mode 100644 index 0000000..670d939 --- /dev/null +++ b/packages/connection_repository/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/packages/connection_repository/coverage_badge.svg b/packages/connection_repository/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/packages/connection_repository/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/connection_repository/lib/connection_repository.dart b/packages/connection_repository/lib/connection_repository.dart new file mode 100644 index 0000000..685f370 --- /dev/null +++ b/packages/connection_repository/lib/connection_repository.dart @@ -0,0 +1,4 @@ +/// Repository to manage the connection state. +library; + +export 'src/connection_repository.dart'; diff --git a/packages/connection_repository/lib/src/connection_repository.dart b/packages/connection_repository/lib/src/connection_repository.dart new file mode 100644 index 0000000..92c6d86 --- /dev/null +++ b/packages/connection_repository/lib/src/connection_repository.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:gyver_lamp_client/gyver_lamp_client.dart'; +import 'package:meta/meta.dart'; + +/// {@template connection_exception} +/// Exception thrown when connection process fails. +/// {@endtemplate} +class ConnectionException implements Exception { + /// {@macro connection_exception} + const ConnectionException(this.error, this.stackTrace); + + /// The error that was caught. + final Object error; + + /// The stack trace associated with the error. + final StackTrace stackTrace; +} + +/// Represents the current status of the connection +enum ConnectionStatus { + /// Represents a connection status. + connecting, + + /// Represents a connected status. + connected, + + /// Represents a disconnected status. + disconnected, +} + +/// {@template connection_repository} +/// Repository to manage connection. +/// {@endtemplate} +class ConnectionRepository { + /// {@macro connection_repository} + ConnectionRepository({ + required GyverLampClient client, + Duration period = const Duration(seconds: 2), + }) : _client = client, + _period = period, + _controller = StreamController.broadcast(); + + final GyverLampClient _client; + + final Duration _period; + + final StreamController _controller; + + StreamSubscription? _subscription; + + /// The stream of connection statuses. + Stream get statuses => _controller.stream.distinct(); + + /// Whether this repository is disposed + /// + /// When repository is disposed it can't be used anymore. + @visibleForTesting + bool get isDisposed => _controller.isClosed; + + /// Disposes any internal resources. + Future dispose() async { + await disconnect(); + await _controller.close(); + } + + /// Pings the lamp, and if it was successful, constantly pings it until + /// [disconnect] is called. + Future connect({ + required String address, + required int port, + }) async { + try { + _controller.add(ConnectionStatus.connecting); + + await _client.ping( + address: address, + port: port, + ); + + _controller.add(ConnectionStatus.connected); + + _subscription = Stream.periodic( + _period, + (_) => _, + ).listen( + (_) async { + if (_subscription == null || _controller.isClosed) { + return; + } + + try { + await _client.ping( + address: address, + port: port, + ); + + _controller.add(ConnectionStatus.connected); + } catch (e) { + await disconnect(); + } + }, + cancelOnError: true, + ); + } catch (e, t) { + _controller.add(ConnectionStatus.disconnected); + throw ConnectionException(e, t); + } + } + + /// Stops constant sending of the ping requests to the lamp + Future disconnect() async { + _controller.add(ConnectionStatus.disconnected); + + await _subscription?.cancel(); + _subscription = null; + } +} diff --git a/packages/connection_repository/pubspec.yaml b/packages/connection_repository/pubspec.yaml new file mode 100644 index 0000000..c49dde1 --- /dev/null +++ b/packages/connection_repository/pubspec.yaml @@ -0,0 +1,18 @@ +name: connection_repository +description: Repository to manage the connection state. +version: 1.0.0 +publish_to: none + +environment: + sdk: ">=3.0.5 <4.0.0" + +dependencies: + gyver_lamp_client: + path: ../gyver_lamp_client + meta: ^1.9.1 + +dev_dependencies: + fake_async: 1.3.1 + mocktail: 1.0.0 + test: 1.24.6 + very_good_analysis: 5.1.0 diff --git a/packages/connection_repository/test/connection_repository_test.dart b/packages/connection_repository/test/connection_repository_test.dart new file mode 100644 index 0000000..0904499 --- /dev/null +++ b/packages/connection_repository/test/connection_repository_test.dart @@ -0,0 +1,358 @@ +import 'dart:async'; + +import 'package:connection_repository/connection_repository.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:gyver_lamp_client/gyver_lamp_client.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockGyverLampClient extends Mock implements GyverLampClient {} + +void main() { + group('ConnectionRepository', () { + const address = '192.168.1.1'; + const port = 8888; + + const period = Duration(seconds: 1); + + const expectedResponse = GyverLampOkResponse(timestamp: '11.22.63'); + + late GyverLampClient client; + late ConnectionRepository subject; + + setUp(() { + client = _MockGyverLampClient(); + + when( + () => client.ping( + address: any(named: 'address'), + port: any( + named: 'port', + ), + ), + ).thenAnswer( + (_) async => expectedResponse, + ); + + subject = ConnectionRepository( + client: client, + period: period, + ); + }); + + tearDown(() async { + if (subject.isDisposed) { + return; + } + + await subject.dispose(); + }); + + test('can be instantiated', () { + expect( + ConnectionRepository(client: client), + isNotNull, + ); + }); + + group('connect', () { + test( + 'calls ping', + () async { + await subject.connect( + address: address, + port: port, + ); + + verify( + () => client.ping( + address: address, + port: port, + ), + ).called(1); + }, + ); + + test( + 'emits ConnectionStatus.connecting and ConnectionStatus.connected ' + 'when ping returns response', () { + fakeAsync((async) { + final emittedStatuses = []; + + final subscription = subject.statuses.listen( + emittedStatuses.add, + ); + + subject + .connect( + address: address, + port: port, + ) + .then((_) {}); + + async.elapse(Duration.zero); + + expect( + emittedStatuses, + equals( + [ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ], + ), + ); + + subscription.cancel(); + + async.elapse(Duration.zero); + }); + }); + + test( + 'emits ConnectionStatus.connecting, ConnectionStatus.disconnected ' + 'and throws ConnectionException when ping throws', () { + when( + () => client.ping( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenThrow( + ArgumentError('TEST'), + ); + + fakeAsync((async) { + Object? exception; + + final emittedStatuses = []; + + final subscription = subject.statuses.listen( + emittedStatuses.add, + ); + + subject + .connect( + address: address, + port: port, + ) + .then((_) {}) + .onError( + (e, t) { + exception = e; + }, + ); + + async.elapse(period); + + expect(exception, isNotNull); + expect( + exception, + isA().having( + (e) => e.error, + 'error', + isA().having( + (e) => e.message, + 'message', + 'TEST', + ), + ), + ); + + expect( + emittedStatuses, + equals( + [ + ConnectionStatus.connecting, + ConnectionStatus.disconnected, + ], + ), + ); + + subscription.cancel(); + + async.elapse(Duration.zero); + }); + }); + + test('constantly pings lamp with the period delays', () { + const count = 3; + + fakeAsync((async) { + final emittedStatuses = []; + + final subscription = subject.statuses.listen( + emittedStatuses.add, + ); + + subject + .connect( + address: address, + port: port, + ) + .then((_) {}); + + async.elapse(period * count); + + expect( + emittedStatuses, + equals( + [ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ], + ), + ); + + subscription.cancel(); + + async.elapse(Duration.zero); + }); + + verify( + () => client.ping( + address: address, + port: port, + ), + ).called(count + 1); + }); + + test('stops constantly pings after an exception in ping and disconnects', + () { + var shouldThrow = false; + + when( + () => client.ping( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenAnswer((_) async { + if (!shouldThrow) { + shouldThrow = true; + return expectedResponse; + } + + throw ArgumentError('TEST'); + }); + + fakeAsync((async) { + final emittedStatuses = []; + + final subscription = subject.statuses.listen( + emittedStatuses.add, + ); + + subject + .connect( + address: address, + port: port, + ) + .then((_) {}); + + async.elapse(period); + + expect( + emittedStatuses, + equals( + [ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ConnectionStatus.disconnected, + ], + ), + ); + + subscription.cancel(); + + async.elapse(Duration.zero); + }); + + verify( + () => client.ping( + address: address, + port: port, + ), + ).called(2); + }); + }); + + group('disconnect', () { + test('stops constant pings and emits ConnectionStatus.disconnected', () { + fakeAsync((async) { + final emittedStatuses = []; + + final subscription = subject.statuses.listen( + emittedStatuses.add, + ); + + subject + .connect( + address: address, + port: port, + ) + .then((_) {}); + + async.elapse(period); + + subject.disconnect().then((_) {}); + + async.elapse(period * 2); + + expect( + emittedStatuses, + equals( + [ + ConnectionStatus.connecting, + ConnectionStatus.connected, + ConnectionStatus.disconnected, + ], + ), + ); + + subscription.cancel(); + + async.elapse(Duration.zero); + }); + + verify( + () => client.ping( + address: address, + port: port, + ), + ).called(2); + }); + }); + + group('dispose', () { + test( + 'disposes internal resources', + () async { + await subject.dispose(); + expect(subject.isDisposed, isTrue); + }, + ); + + test('stops constant pings', () { + fakeAsync((async) { + subject + .connect( + address: address, + port: port, + ) + .then((_) {}); + + async.elapse(period); + + subject.dispose().then((_) {}); + + async.elapse(period * 3); + }); + + verify( + () => client.ping( + address: address, + port: port, + ), + ).called(2); + }); + }); + }); +} diff --git a/packages/control_repository/.gitignore b/packages/control_repository/.gitignore new file mode 100644 index 0000000..4fe5c69 --- /dev/null +++ b/packages/control_repository/.gitignore @@ -0,0 +1,15 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock + +# 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/ + +# Coverage +coverage/ diff --git a/packages/control_repository/README.md b/packages/control_repository/README.md new file mode 100644 index 0000000..c822191 --- /dev/null +++ b/packages/control_repository/README.md @@ -0,0 +1,13 @@ +# Control Repository + +[![coverage][coverage_badge]][coverage_badge] +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +Repository to control the Gyver Lamp. + +[coverage_badge]: https://raw.githubusercontent.com/VeryGoodOpenSource/very_good_cli/main/coverage_badge.svg +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT \ No newline at end of file diff --git a/packages/control_repository/analysis_options.yaml b/packages/control_repository/analysis_options.yaml new file mode 100644 index 0000000..670d939 --- /dev/null +++ b/packages/control_repository/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/packages/control_repository/coverage_badge.svg b/packages/control_repository/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/packages/control_repository/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/control_repository/lib/control_repository.dart b/packages/control_repository/lib/control_repository.dart new file mode 100644 index 0000000..11f9930 --- /dev/null +++ b/packages/control_repository/lib/control_repository.dart @@ -0,0 +1,4 @@ +/// Repository to control the lamp. +library; + +export 'src/src.dart'; diff --git a/packages/control_repository/lib/src/control_repository.dart b/packages/control_repository/lib/src/control_repository.dart new file mode 100644 index 0000000..01571c3 --- /dev/null +++ b/packages/control_repository/lib/src/control_repository.dart @@ -0,0 +1,215 @@ +import 'dart:async'; + +import 'package:control_repository/src/src.dart'; +import 'package:gyver_lamp_client/gyver_lamp_client.dart'; +import 'package:meta/meta.dart'; + +/// {@template control_exception} +/// Exception thrown when control action fails. +/// {@endtemplate} +class ControlException implements Exception { + /// {@macro control_exception} + const ControlException(this.error, this.stackTrace); + + /// The error that was caught. + final Object error; + + /// The stack trace associated with the error. + final StackTrace stackTrace; +} + +/// {@template control_repository} +/// Repository to control the lamp. +/// {@endtemplate} +class ControlRepository { + /// {@macro control_repository} + ControlRepository({ + required GyverLampClient client, + }) : _client = client, + _controller = StreamController.broadcast(); + + final GyverLampClient _client; + + final StreamController _controller; + StreamSubscription? _subscription; + + /// Whether this repository is disposed + /// + /// When repository is disposed it can't be used anymore. + @visibleForTesting + bool get isDisposed => _controller.isClosed; + + /// The stream of messages from lamp. + Stream get messages { + _subscription ??= _client.responses.listen((response) { + switch (response) { + case GyverLampCurrentResponse(): + _controller.add( + GyverLampStateChangedMessage( + mode: GyverLampMode.fromIndex(response.mode), + brightness: response.brightness, + speed: response.speed, + scale: response.scale, + isOn: response.isOn, + ), + ); + + case GyverLampBrightnessResponse(): + _controller.add( + GyverLampBrightnessChangedMessage(brightness: response.brightness), + ); + + case GyverLampSpeedResponse(): + _controller.add( + GyverLampSpeedChangedMessage(speed: response.speed), + ); + + case GyverLampScaleResponse(): + _controller.add( + GyverLampScaleChangedMessage(scale: response.scale), + ); + + case GyverLampOkResponse(): + case GyverLampUnknownResponse(): + // just ignore those + } + }); + + return _controller.stream; + } + + /// Requests the current state of the lamp. + /// + /// Throws a [ControlException] in case of exceptions from client. + Future requestCurrentState({ + required String address, + required int port, + }) async { + try { + await _client.getCurrentState( + address: address, + port: port, + ); + } catch (e, t) { + throw ControlException(e, t); + } + } + + /// Updates the currently selected mode on the lamp. + /// + /// Throws a [ControlException] in case of exceptions from client. + Future setMode({ + required String address, + required int port, + required GyverLampMode mode, + }) async { + try { + await _client.setMode( + address: address, + port: port, + mode: mode.index, + ); + } catch (e, t) { + throw ControlException(e, t); + } + } + + /// Updates the brightness level of the selected mode on the lamp. + /// + /// Throws a [ControlException] in case of exceptions from client. + Future setBrightness({ + required String address, + required int port, + required int brightness, + }) async { + try { + await _client.setBrightness( + address: address, + port: port, + brightness: brightness, + ); + } catch (e, t) { + throw ControlException(e, t); + } + } + + /// Updates the speed value of the selected mode on the lamp. + /// + /// Throws a [ControlException] in case of exceptions from client. + Future setSpeed({ + required String address, + required int port, + required int speed, + }) async { + try { + await _client.setSpeed( + address: address, + port: port, + speed: speed, + ); + } catch (e, t) { + throw ControlException(e, t); + } + } + + /// Updates the speed value of the selected mode on the lamp. + /// + /// Throws a [ControlException] in case of exceptions from client. + Future setScale({ + required String address, + required int port, + required int scale, + }) async { + try { + await _client.setScale( + address: address, + port: port, + scale: scale, + ); + } catch (e, t) { + throw ControlException(e, t); + } + } + + /// Turns on the lamp. + /// + /// Throws a [ControlException] in case of exceptions from client. + Future turnOn({ + required String address, + required int port, + }) async { + try { + await _client.turnOn( + address: address, + port: port, + ); + } catch (e, t) { + throw ControlException(e, t); + } + } + + /// Turns off the lamp. + /// + /// Throws a [ControlException] in case of exceptions from client. + Future turnOff({ + required String address, + required int port, + }) async { + try { + await _client.turnOff( + address: address, + port: port, + ); + } catch (e, t) { + throw ControlException(e, t); + } + } + + /// Disposes any internal resources. + void dispose() { + _subscription?.cancel(); + _subscription = null; + + _controller.close(); + } +} diff --git a/packages/control_repository/lib/src/models/message.dart b/packages/control_repository/lib/src/models/message.dart new file mode 100644 index 0000000..d87ec1e --- /dev/null +++ b/packages/control_repository/lib/src/models/message.dart @@ -0,0 +1,93 @@ +import 'package:control_repository/src/models/mode.dart'; +import 'package:equatable/equatable.dart'; + +/// {@template gyver_lamp_message} +/// The message received from the lamp. +/// {@endtemplate} +sealed class GyverLampMessage extends Equatable { + /// {@macro gyver_lamp_message} + const GyverLampMessage(); + + @override + List get props; +} + +/// {@template gyver_lamp_state_changed_message} +/// The message received from the lamp which indicates state change. +/// {@endtemplate} +class GyverLampStateChangedMessage extends GyverLampMessage { + /// {@macro gyver_lamp_state_changed_message} + const GyverLampStateChangedMessage({ + required this.mode, + required this.brightness, + required this.speed, + required this.scale, + required this.isOn, + }); + + /// The current selected mode. + final GyverLampMode mode; + + /// The brightness level of the current mode. + final int brightness; + + /// The speed value of the current mode. + final int speed; + + /// The scale value of the current mode. + final int scale; + + /// Whether lamp is enabled. + final bool isOn; + + @override + List get props => [mode, brightness, speed, scale, isOn]; +} + +/// {@template gyver_lamp_brightness_changed_message} +/// The message received from the lamp after the brightness level change. +/// {@endtemplate} +class GyverLampBrightnessChangedMessage extends GyverLampMessage { + /// {@macro gyver_lamp_brightness_changed_message} + const GyverLampBrightnessChangedMessage({ + required this.brightness, + }); + + /// The brightness level of the current mode. + final int brightness; + + @override + List get props => [brightness]; +} + +/// {@template gyver_lamp_speed_changed_message} +/// The message received from the lamp after the speed value change. +/// {@endtemplate} +class GyverLampSpeedChangedMessage extends GyverLampMessage { + /// {@macro gyver_lamp_speed_changed_message} + const GyverLampSpeedChangedMessage({ + required this.speed, + }); + + /// The speed value of the current mode. + final int speed; + + @override + List get props => [speed]; +} + +/// {@template gyver_lamp_scale_changed_message} +/// The message received from the lamp after the scale value change. +/// {@endtemplate} +class GyverLampScaleChangedMessage extends GyverLampMessage { + /// {@macro gyver_lamp_scale_changed_message} + const GyverLampScaleChangedMessage({ + required this.scale, + }); + + /// The scale value of the current mode. + final int scale; + + @override + List get props => [scale]; +} diff --git a/packages/control_repository/lib/src/models/mode.dart b/packages/control_repository/lib/src/models/mode.dart new file mode 100644 index 0000000..672a655 --- /dev/null +++ b/packages/control_repository/lib/src/models/mode.dart @@ -0,0 +1,61 @@ +/// Represents the lamp mode. +enum GyverLampMode { + /// Represents a sparkles mode. + sparkles, + + /// Represents a fire mode. + fire, + + /// Represents a vertical rainbow mode. + rainbowVertical, + + /// Represents a horizontal rainbow mode. + rainbowHorizontal, + + /// Represents a colors mode. + colors, + + /// Represents a madness mode. + madness, + + /// Represents a cloud mode. + cloud, + + /// Represents a lava mode. + lava, + + /// Represents a plasma mode. + plasma, + + /// Represents a rainbow mode. + rainbow, + + /// Represents a rainbow stripes mode. + rainbowStripes, + + /// Represents a zebra mode. + zebra, + + /// Represents a forest mode. + forest, + + /// Represents a ocean mode. + ocean, + + /// Represents a color mode. + color, + + /// Represents a snow mode. + snow, + + /// Represents a matrix mode. + matrix, + + /// Represents a fireflies mode. + fireflies; + + /// Returns [GyverLampMode] for the given [index] + static GyverLampMode fromIndex(int index) { + return GyverLampMode.values[index]; + } +} diff --git a/packages/control_repository/lib/src/src.dart b/packages/control_repository/lib/src/src.dart new file mode 100644 index 0000000..429abf5 --- /dev/null +++ b/packages/control_repository/lib/src/src.dart @@ -0,0 +1,3 @@ +export 'control_repository.dart'; +export 'models/message.dart'; +export 'models/mode.dart'; diff --git a/packages/control_repository/pubspec.yaml b/packages/control_repository/pubspec.yaml new file mode 100644 index 0000000..0f7b7f9 --- /dev/null +++ b/packages/control_repository/pubspec.yaml @@ -0,0 +1,19 @@ +name: control_repository +description: Repository to control the Gyver Lamp. +version: 1.0.0 +publish_to: none + +environment: + sdk: ">=3.0.5 <4.0.0" + +dependencies: + equatable: ^2.0.5 + gyver_lamp_client: + path: ../gyver_lamp_client + meta: ^1.9.1 + +dev_dependencies: + fake_async: 1.3.1 + mocktail: 1.0.0 + test: 1.24.4 + very_good_analysis: 5.1.0 diff --git a/packages/control_repository/test/control_repository_test.dart b/packages/control_repository/test/control_repository_test.dart new file mode 100644 index 0000000..28c35ff --- /dev/null +++ b/packages/control_repository/test/control_repository_test.dart @@ -0,0 +1,647 @@ +import 'dart:async'; + +import 'package:control_repository/control_repository.dart'; +import 'package:gyver_lamp_client/gyver_lamp_client.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockGyverLampClient extends Mock implements GyverLampClient {} + +void main() { + group('ControlRepository', () { + const address = '192.168.1.1'; + const port = 8888; + + const okResponse = GyverLampOkResponse(timestamp: '11.22.63'); + + late GyverLampClient client; + late ControlRepository subject; + + setUp(() { + client = _MockGyverLampClient(); + subject = ControlRepository(client: client); + }); + + tearDown(() async { + if (subject.isDisposed) { + return; + } + + subject.dispose(); + }); + + test('can be instantiated', () { + expect( + ControlRepository(client: client), + isNotNull, + ); + }); + + group('requestCurrentState', () { + test('calls GyverLampClient.getCurrentState', () async { + when( + () => client.getCurrentState( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenAnswer((_) async => okResponse); + + await subject.requestCurrentState( + address: address, + port: port, + ); + + verify( + () => client.getCurrentState(address: address, port: port), + ).called(1); + }); + + test( + 'throws ControlException when GyverLampClient.getCurrentState throws', + () async { + when( + () => client.getCurrentState( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenThrow( + ArgumentError('TEST'), + ); + + await expectLater( + () async => subject.requestCurrentState( + address: address, + port: port, + ), + throwsA( + isA().having( + (e) => e.error, + 'error', + isA().having( + (e) => e.message, + 'message', + equals('TEST'), + ), + ), + ), + ); + }, + ); + }); + + group('setMode', () { + test('calls GyverLampClient.setMode', () async { + when( + () => client.setMode( + address: any(named: 'address'), + port: any(named: 'port'), + mode: any(named: 'mode'), + ), + ).thenAnswer((_) async => okResponse); + + await subject.setMode( + address: address, + port: port, + mode: GyverLampMode.fire, + ); + + verify( + () => client.setMode( + address: address, + port: port, + mode: GyverLampMode.fire.index, + ), + ).called(1); + }); + + test( + 'throws ControlException when GyverLampClient.setMode throws', + () async { + when( + () => client.setMode( + address: any(named: 'address'), + port: any(named: 'port'), + mode: any(named: 'mode'), + ), + ).thenThrow( + ArgumentError('TEST'), + ); + + await expectLater( + () async => subject.setMode( + address: address, + port: port, + mode: GyverLampMode.fire, + ), + throwsA( + isA().having( + (e) => e.error, + 'error', + isA().having( + (e) => e.message, + 'message', + equals('TEST'), + ), + ), + ), + ); + }, + ); + }); + + group('setBrightness', () { + test('calls GyverLampClient.setBrightness', () async { + when( + () => client.setBrightness( + address: any(named: 'address'), + port: any(named: 'port'), + brightness: any(named: 'brightness'), + ), + ).thenAnswer((_) async => okResponse); + + await subject.setBrightness( + address: address, + port: port, + brightness: 33, + ); + + verify( + () => client.setBrightness( + address: address, + port: port, + brightness: 33, + ), + ).called(1); + }); + + test( + 'throws ControlException when GyverLampClient.setBrightness throws', + () async { + when( + () => client.setBrightness( + address: any(named: 'address'), + port: any(named: 'port'), + brightness: any(named: 'brightness'), + ), + ).thenThrow( + ArgumentError('TEST'), + ); + + await expectLater( + () async => subject.setBrightness( + address: address, + port: port, + brightness: 33, + ), + throwsA( + isA().having( + (e) => e.error, + 'error', + isA().having( + (e) => e.message, + 'message', + equals('TEST'), + ), + ), + ), + ); + }, + ); + }); + + group('setSpeed', () { + test('calls GyverLampClient.setSpeed', () async { + when( + () => client.setSpeed( + address: any(named: 'address'), + port: any(named: 'port'), + speed: any(named: 'speed'), + ), + ).thenAnswer((_) async => okResponse); + + await subject.setSpeed( + address: address, + port: port, + speed: 33, + ); + + verify( + () => client.setSpeed( + address: address, + port: port, + speed: 33, + ), + ).called(1); + }); + + test( + 'throws ControlException when GyverLampClient.setSpeed throws', + () async { + when( + () => client.setSpeed( + address: any(named: 'address'), + port: any(named: 'port'), + speed: any(named: 'speed'), + ), + ).thenThrow( + ArgumentError('TEST'), + ); + + await expectLater( + () async => subject.setSpeed( + address: address, + port: port, + speed: 33, + ), + throwsA( + isA().having( + (e) => e.error, + 'error', + isA().having( + (e) => e.message, + 'message', + equals('TEST'), + ), + ), + ), + ); + }, + ); + }); + + group('setScale', () { + test('calls GyverLampClient.setScale', () async { + when( + () => client.setScale( + address: any(named: 'address'), + port: any(named: 'port'), + scale: any(named: 'scale'), + ), + ).thenAnswer((_) async => okResponse); + + await subject.setScale( + address: address, + port: port, + scale: 33, + ); + + verify( + () => client.setScale( + address: address, + port: port, + scale: 33, + ), + ).called(1); + }); + + test( + 'throws ControlException when GyverLampClient.setScale throws', + () async { + when( + () => client.setScale( + address: any(named: 'address'), + port: any(named: 'port'), + scale: any(named: 'scale'), + ), + ).thenThrow( + ArgumentError('TEST'), + ); + + await expectLater( + () async => subject.setScale( + address: address, + port: port, + scale: 33, + ), + throwsA( + isA().having( + (e) => e.error, + 'error', + isA().having( + (e) => e.message, + 'message', + equals('TEST'), + ), + ), + ), + ); + }, + ); + }); + + group('turnOn', () { + test('calls GyverLampClient.turnOn', () async { + when( + () => client.turnOn( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenAnswer((_) async => okResponse); + + await subject.turnOn( + address: address, + port: port, + ); + + verify( + () => client.turnOn( + address: address, + port: port, + ), + ).called(1); + }); + + test( + 'throws ControlException when GyverLampClient.turnOn throws', + () async { + when( + () => client.turnOn( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenThrow( + ArgumentError('TEST'), + ); + + await expectLater( + () async => subject.turnOn( + address: address, + port: port, + ), + throwsA( + isA().having( + (e) => e.error, + 'error', + isA().having( + (e) => e.message, + 'message', + equals('TEST'), + ), + ), + ), + ); + }, + ); + }); + + group('turnOff', () { + test('calls GyverLampClient.turnOff', () async { + when( + () => client.turnOff( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenAnswer((_) async => okResponse); + + await subject.turnOff( + address: address, + port: port, + ); + + verify( + () => client.turnOff( + address: address, + port: port, + ), + ).called(1); + }); + + test( + 'throws ControlException when GyverLampClient.turnOff throws', + () async { + when( + () => client.turnOff( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenThrow( + ArgumentError('TEST'), + ); + + await expectLater( + () async => subject.turnOff( + address: address, + port: port, + ), + throwsA( + isA().having( + (e) => e.error, + 'error', + isA().having( + (e) => e.message, + 'message', + equals('TEST'), + ), + ), + ), + ); + }, + ); + }); + + group('messages', () { + test( + 'emits GyverLampStateChangedMessage when client emits ' + 'GyverLampCurrentResponse', + () async { + when(() => client.responses).thenAnswer( + (_) => Stream.fromIterable([ + const GyverLampCurrentResponse( + mode: 0, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + ]), + ); + + expect( + subject.messages, + emits( + GyverLampStateChangedMessage( + mode: GyverLampMode.fromIndex(0), + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + ), + ); + }, + ); + + test( + 'emits GyverLampBrightnessChangedMessage when client emits ' + 'GyverLampBrightnessResponse', + () async { + when(() => client.responses).thenAnswer( + (_) => Stream.fromIterable([ + const GyverLampBrightnessResponse(brightness: 1), + ]), + ); + + expect( + subject.messages, + emits(const GyverLampBrightnessChangedMessage(brightness: 1)), + ); + }, + ); + + test( + 'emits GyverLampSpeedChangedMessage when client emits ' + 'GyverLampCurrentResponse', + () async { + when(() => client.responses).thenAnswer( + (_) => Stream.fromIterable([ + const GyverLampSpeedResponse(speed: 2), + ]), + ); + + expect( + subject.messages, + emits(const GyverLampSpeedChangedMessage(speed: 2)), + ); + }, + ); + + test( + 'emits GyverLampStateChangedMessage when client emits ' + 'GyverLampScaleResponse', + () async { + when(() => client.responses).thenAnswer( + (_) => Stream.fromIterable([ + const GyverLampScaleResponse(scale: 3), + ]), + ); + + expect( + subject.messages, + emits(const GyverLampScaleChangedMessage(scale: 3)), + ); + }, + ); + + test( + 'emits nothing when client emits GyverLampOkResponse', + () async { + when(() => client.responses).thenAnswer( + (_) => Stream.fromIterable([ + const GyverLampOkResponse(timestamp: '11.22.63'), + ]), + ); + + final messages = []; + + final subscription = subject.messages.listen(messages.add); + await Future.delayed(Duration.zero); + + await subscription.cancel(); + + expect(messages, isEmpty); + }, + ); + + test( + 'emits nothing when client emits GyverLampUnknownResponse', + () async { + when(() => client.responses).thenAnswer( + (_) => Stream.fromIterable([ + const GyverLampUnknownResponse(data: '123'), + ]), + ); + + final messages = []; + + final subscription = subject.messages.listen(messages.add); + await Future.delayed(Duration.zero); + + await subscription.cancel(); + + expect(messages, isEmpty); + }, + ); + + test('subscribes to the GyverLampClient.responses', () async { + final responses = StreamController(); + addTearDown(responses.close); + when(() => client.responses).thenAnswer((_) => responses.stream); + + final messages = []; + final subscription = subject.messages.listen(messages.add); + addTearDown(subscription.cancel); + + responses + ..add(const GyverLampBrightnessResponse(brightness: 1)) + ..add(const GyverLampSpeedResponse(speed: 2)); + await Future.delayed(Duration.zero); + + expect( + messages, + equals([ + const GyverLampBrightnessChangedMessage(brightness: 1), + const GyverLampSpeedChangedMessage(speed: 2), + ]), + ); + + messages.clear(); + + responses.add( + const GyverLampCurrentResponse( + mode: 0, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + ); + await Future.delayed(Duration.zero); + + expect( + messages, + equals([ + GyverLampStateChangedMessage( + mode: GyverLampMode.fromIndex(0), + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + ]), + ); + }); + }); + + group('dispose', () { + test('cancels subscription to the GyverLampClient.responses', () async { + final responses = StreamController(); + addTearDown(responses.close); + when(() => client.responses).thenAnswer((_) => responses.stream); + + final messages = []; + final subscription = subject.messages.listen(messages.add); + addTearDown(subscription.cancel); + + responses.add(const GyverLampBrightnessResponse(brightness: 1)); + await Future.delayed(Duration.zero); + + expect( + messages, + equals([ + const GyverLampBrightnessChangedMessage(brightness: 1), + ]), + ); + + messages.clear(); + + subject.dispose(); + + responses.add(const GyverLampSpeedResponse(speed: 2)); + await Future.delayed(Duration.zero); + + expect(subject.isDisposed, isTrue); + expect(messages, isEmpty); + }); + }); + }); +} diff --git a/packages/control_repository/test/models/message_test.dart b/packages/control_repository/test/models/message_test.dart new file mode 100644 index 0000000..7867c0f --- /dev/null +++ b/packages/control_repository/test/models/message_test.dart @@ -0,0 +1,216 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:control_repository/control_repository.dart'; +import 'package:test/test.dart'; + +void main() { + group('GyverLampStateChangedMessage', () { + test('can be instantiated', () { + expect( + GyverLampStateChangedMessage( + mode: GyverLampMode.fire, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + GyverLampStateChangedMessage( + mode: GyverLampMode.fire, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + equals( + GyverLampStateChangedMessage( + mode: GyverLampMode.fire, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + ), + ); + + expect( + GyverLampStateChangedMessage( + mode: GyverLampMode.fire, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + isNot( + equals( + GyverLampStateChangedMessage( + mode: GyverLampMode.cloud, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + ), + ), + ); + + expect( + GyverLampStateChangedMessage( + mode: GyverLampMode.fire, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + isNot( + equals( + GyverLampStateChangedMessage( + mode: GyverLampMode.fire, + brightness: 4, + speed: 2, + scale: 3, + isOn: true, + ), + ), + ), + ); + + expect( + GyverLampStateChangedMessage( + mode: GyverLampMode.fire, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + isNot( + equals( + GyverLampStateChangedMessage( + mode: GyverLampMode.fire, + brightness: 1, + speed: 4, + scale: 3, + isOn: true, + ), + ), + ), + ); + + expect( + GyverLampStateChangedMessage( + mode: GyverLampMode.fire, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + isNot( + equals( + GyverLampStateChangedMessage( + mode: GyverLampMode.fire, + brightness: 1, + speed: 2, + scale: 4, + isOn: true, + ), + ), + ), + ); + + expect( + GyverLampStateChangedMessage( + mode: GyverLampMode.fire, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + isNot( + equals( + GyverLampStateChangedMessage( + mode: GyverLampMode.fire, + brightness: 1, + speed: 2, + scale: 2, + isOn: false, + ), + ), + ), + ); + }); + }); + + group('GyverLampBrightnessChangedMessage', () { + test('can be instantiated', () { + expect( + GyverLampBrightnessChangedMessage(brightness: 1), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + GyverLampBrightnessChangedMessage(brightness: 1), + equals(GyverLampBrightnessChangedMessage(brightness: 1)), + ); + + expect( + GyverLampBrightnessChangedMessage(brightness: 1), + isNot( + equals(GyverLampBrightnessChangedMessage(brightness: 2)), + ), + ); + }); + }); + + group('GyverLampSpeedChangedMessage', () { + test('can be instantiated', () { + expect( + GyverLampSpeedChangedMessage(speed: 1), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + GyverLampSpeedChangedMessage(speed: 1), + equals(GyverLampSpeedChangedMessage(speed: 1)), + ); + + expect( + GyverLampSpeedChangedMessage(speed: 1), + isNot( + equals(GyverLampSpeedChangedMessage(speed: 2)), + ), + ); + }); + }); + + group('GyverLampScaleChangedMessage', () { + test('can be instantiated', () { + expect( + GyverLampScaleChangedMessage(scale: 1), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + GyverLampScaleChangedMessage(scale: 1), + equals(GyverLampScaleChangedMessage(scale: 1)), + ); + + expect( + GyverLampScaleChangedMessage(scale: 1), + isNot( + equals(GyverLampScaleChangedMessage(scale: 2)), + ), + ); + }); + }); +} diff --git a/packages/control_repository/test/models/mode_test.dart b/packages/control_repository/test/models/mode_test.dart new file mode 100644 index 0000000..1b601d8 --- /dev/null +++ b/packages/control_repository/test/models/mode_test.dart @@ -0,0 +1,81 @@ +import 'package:control_repository/control_repository.dart'; +import 'package:test/test.dart'; + +void main() { + group('GyverLampMode', () { + test('fromIndex returns correct mode', () { + expect( + GyverLampMode.fromIndex(0), + equals(GyverLampMode.sparkles), + ); + expect( + GyverLampMode.fromIndex(1), + equals(GyverLampMode.fire), + ); + expect( + GyverLampMode.fromIndex(2), + equals(GyverLampMode.rainbowVertical), + ); + expect( + GyverLampMode.fromIndex(3), + equals(GyverLampMode.rainbowHorizontal), + ); + expect( + GyverLampMode.fromIndex(4), + equals(GyverLampMode.colors), + ); + expect( + GyverLampMode.fromIndex(5), + equals(GyverLampMode.madness), + ); + expect( + GyverLampMode.fromIndex(6), + equals(GyverLampMode.cloud), + ); + expect( + GyverLampMode.fromIndex(7), + equals(GyverLampMode.lava), + ); + expect( + GyverLampMode.fromIndex(8), + equals(GyverLampMode.plasma), + ); + expect( + GyverLampMode.fromIndex(9), + equals(GyverLampMode.rainbow), + ); + expect( + GyverLampMode.fromIndex(10), + equals(GyverLampMode.rainbowStripes), + ); + expect( + GyverLampMode.fromIndex(11), + equals(GyverLampMode.zebra), + ); + expect( + GyverLampMode.fromIndex(12), + equals(GyverLampMode.forest), + ); + expect( + GyverLampMode.fromIndex(13), + equals(GyverLampMode.ocean), + ); + expect( + GyverLampMode.fromIndex(14), + equals(GyverLampMode.color), + ); + expect( + GyverLampMode.fromIndex(15), + equals(GyverLampMode.snow), + ); + expect( + GyverLampMode.fromIndex(16), + equals(GyverLampMode.matrix), + ); + expect( + GyverLampMode.fromIndex(17), + equals(GyverLampMode.fireflies), + ); + }); + }); +} diff --git a/packages/gyver_lamp_client/.gitignore b/packages/gyver_lamp_client/.gitignore new file mode 100644 index 0000000..4fe5c69 --- /dev/null +++ b/packages/gyver_lamp_client/.gitignore @@ -0,0 +1,15 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock + +# 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/ + +# Coverage +coverage/ diff --git a/packages/gyver_lamp_client/CHANGELOG.md b/packages/gyver_lamp_client/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/gyver_lamp_client/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/gyver_lamp_client/README.md b/packages/gyver_lamp_client/README.md new file mode 100644 index 0000000..cf7b3ef --- /dev/null +++ b/packages/gyver_lamp_client/README.md @@ -0,0 +1,13 @@ +# Gyver Lamp Client + +[![coverage][coverage_badge]][coverage_badge] +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +Client to communicate with the Gyver Lamp. + +[coverage_badge]: https://raw.githubusercontent.com/VeryGoodOpenSource/very_good_cli/main/coverage_badge.svg +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT \ No newline at end of file diff --git a/packages/gyver_lamp_client/analysis_options.yaml b/packages/gyver_lamp_client/analysis_options.yaml new file mode 100644 index 0000000..670d939 --- /dev/null +++ b/packages/gyver_lamp_client/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/packages/gyver_lamp_client/coverage_badge.svg b/packages/gyver_lamp_client/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/packages/gyver_lamp_client/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/gyver_lamp_client/example/gyver_lamp_client_example.dart b/packages/gyver_lamp_client/example/gyver_lamp_client_example.dart new file mode 100644 index 0000000..9e8b6a8 --- /dev/null +++ b/packages/gyver_lamp_client/example/gyver_lamp_client_example.dart @@ -0,0 +1,122 @@ +// ignore_for_file: inference_failure_on_instance_creation, avoid_print + +import 'package:gyver_lamp_client/gyver_lamp_client.dart'; + +const address = '192.168.1.5'; +const port = 8888; + +void main() async { + final client = GyverLampClient(); + + GyverLampResponse response; + + // TURN ON + response = await client.turnOn( + address: address, + port: port, + ); + print(response); + await Future.delayed( + const Duration(seconds: 2), + ); + + // MODE 8 + response = await client.setMode( + address: address, + port: port, + mode: 8, + ); + print(response); + await Future.delayed( + const Duration(seconds: 2), + ); + + // MODE 7 + response = await client.setMode( + address: address, + port: port, + mode: 7, + ); + print(response); + await Future.delayed( + const Duration(seconds: 2), + ); + + // BRIGHTNESS 100 + response = await client.setBrightness( + address: address, + port: port, + brightness: 100, + ); + print(response); + await Future.delayed( + const Duration(seconds: 2), + ); + + // BRIGHTNESS 255 + response = await client.setBrightness( + address: address, + port: port, + brightness: 255, + ); + print(response); + await Future.delayed( + const Duration(seconds: 2), + ); + + // SPEED 50 + response = await client.setSpeed( + address: address, + port: port, + speed: 50, + ); + print(response); + await Future.delayed( + const Duration(seconds: 2), + ); + + // SPEED 30 + response = await client.setSpeed( + address: address, + port: port, + speed: 30, + ); + print(response); + await Future.delayed( + const Duration(seconds: 2), + ); + + // SCALE 30 + response = await client.setScale( + address: address, + port: port, + scale: 30, + ); + print(response); + await Future.delayed( + const Duration(seconds: 2), + ); + + // SCALE 78 + response = await client.setScale( + address: address, + port: port, + scale: 78, + ); + print(response); + await Future.delayed( + const Duration(seconds: 2), + ); + + // TURN OFF + response = await client.turnOff( + address: address, + port: port, + ); + print(response); + await Future.delayed( + const Duration(seconds: 2), + ); + + await client.close(); +} diff --git a/packages/gyver_lamp_client/lib/gyver_lamp_client.dart b/packages/gyver_lamp_client/lib/gyver_lamp_client.dart new file mode 100644 index 0000000..8a3a049 --- /dev/null +++ b/packages/gyver_lamp_client/lib/gyver_lamp_client.dart @@ -0,0 +1,6 @@ +/// Client to communicate with the Gyver Lamp +library gyver_lamp_client; + +export 'src/gyver_lamp_client.dart'; +export 'src/parser.dart'; +export 'src/response.dart'; diff --git a/packages/gyver_lamp_client/lib/src/gyver_lamp_client.dart b/packages/gyver_lamp_client/lib/src/gyver_lamp_client.dart new file mode 100644 index 0000000..fe2f44e --- /dev/null +++ b/packages/gyver_lamp_client/lib/src/gyver_lamp_client.dart @@ -0,0 +1,290 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:gyver_lamp_client/src/src.dart'; + +/// {@template gyver_lamp_client_exception} +/// Exception thrown when data sending or retrieving failed. +/// +/// Check [cause] and [stackTrace] for specific details. +/// {@endtemplate} +class GyverLampClientException implements Exception { + /// {@macro gyver_lamp_client_exception} + const GyverLampClientException(this.cause, this.stackTrace); + + /// The cause of the exception. + final dynamic cause; + + /// The stack trace of the exception. + final StackTrace stackTrace; +} + +/// A factory to create a [GyverLampConnection] instance. +typedef GyverLampConnectionFactory = GyverLampConnection Function({ + RawDatagramSocketFactory socketFactory, + Duration timeout, +}); + +/// A typedef for logging callback. +typedef LoggerCallback = void Function(String address, int port, Object data); + +/// {@template gyver_lamp_client} +/// Wrapper around [GyverLampConnection] for easy and safe communication with +/// the lamp. +/// {@endtemplate} +class GyverLampClient { + /// {@macro gyver_lamp_client} + GyverLampClient({ + RawDatagramSocketFactory socketFactory = RawDatagramSocket.bind, + Duration timeout = const Duration(seconds: 5), + GyverLampConnectionFactory connectionFactory = GyverLampConnection.new, + LoggerCallback? onSend, + LoggerCallback? onResponse, + LoggerCallback? onError, + }) : _connection = connectionFactory( + socketFactory: socketFactory, + timeout: timeout, + ), + _onSend = onSend, + _onResponse = onResponse, + _onError = onError, + _controller = StreamController.broadcast(); + + final GyverLampConnection _connection; + + final StreamController _controller; + + final LoggerCallback? _onSend; + + final LoggerCallback? _onResponse; + + final LoggerCallback? _onError; + + /// The stream of all responses from the client. + Stream get responses => _controller.stream; + + /// Whether the client is closed. + /// + /// When client is closed it can't be used anymore. + bool get isClosed => _controller.isClosed; + + /// Releases the resources. + Future close() async { + await _connection.close(); + await _controller.close(); + } + + /// Requests the current state of the lamp. + /// + /// Typically lamp responds to this request with the + /// [GyverLampCurrentResponse]. + /// + /// Throws a [GyverLampResponseParseException] in case of malformed response. + /// Throws a [GyverLampClientException] when data sending or retrieving + /// failed. + Future getCurrentState({ + required String address, + required int port, + }) async { + return sendRaw( + address: address, + port: port, + event: 'GET', + ); + } + + /// Sends a ping request to the lamp. + /// + /// This function can be used to ensure that lamp is accessible. + /// Typically lamp responds to this request with the + /// [GyverLampOkResponse]. + /// + /// Throws a [GyverLampResponseParseException] in case of malformed response. + /// Throws a [GyverLampClientException] when data sending or retrieving + /// failed. + Future ping({ + required String address, + required int port, + }) async { + return sendRaw( + address: address, + port: port, + event: 'DEB', + ); + } + + /// Updates the currently selected mode on the lamp. + /// + /// Typically lamp responds to this request with the + /// [GyverLampCurrentResponse]. + /// + /// Throws a [GyverLampResponseParseException] in case of malformed response. + /// Throws a [GyverLampClientException] when data sending or retrieving + /// failed. + Future setMode({ + required String address, + required int port, + required int mode, + }) async { + assert( + mode >= 0 && mode <= 17, + 'the mode value range is [0, 17]', + ); + + return sendRaw( + address: address, + port: port, + event: 'EFF $mode', + ); + } + + /// Updates the brightness level of the selected mode on the lamp. + /// + /// Typically lamp responds to this request with the + /// [GyverLampBrightnessResponse]. + /// + /// Throws a [GyverLampResponseParseException] in case of malformed response. + /// Throws a [GyverLampClientException] when data sending or retrieving + /// failed. + Future setBrightness({ + required String address, + required int port, + required int brightness, + }) async { + assert( + brightness > 0 && brightness <= 255, + 'the brightness value range is [1, 255]', + ); + + return sendRaw( + address: address, + port: port, + event: 'BRI $brightness', + ); + } + + /// Updates the speed value of the selected mode on the lamp. + /// + /// Typically lamp responds to this request with the + /// [GyverLampSpeedResponse]. + /// + /// Throws a [GyverLampResponseParseException] in case of malformed response. + /// Throws a [GyverLampClientException] when data sending or retrieving + /// failed. + Future setSpeed({ + required String address, + required int port, + required int speed, + }) async { + assert( + speed > 0 && speed <= 255, + 'the speed value range is [1, 255]', + ); + + return sendRaw( + address: address, + port: port, + event: 'SPD $speed', + ); + } + + /// Updates the scale value of the selected mode on the lamp. + /// + /// Typically lamp responds to this request with the + /// [GyverLampScaleResponse]. + /// + /// Throws a [GyverLampResponseParseException] in case of malformed response. + /// Throws a [GyverLampClientException] when data sending or retrieving + /// failed. + Future setScale({ + required String address, + required int port, + required int scale, + }) async { + assert( + scale > 0 && scale <= 255, + 'the scale value range is [1, 255]', + ); + + return sendRaw( + address: address, + port: port, + event: 'SCA $scale', + ); + } + + /// Turns on the lamp. + /// + /// Typically lamp responds to this request with the + /// [GyverLampCurrentResponse]. + /// + /// Throws a [GyverLampResponseParseException] in case of malformed response. + /// Throws a [GyverLampClientException] when data sending or retrieving + /// failed. + Future turnOn({ + required String address, + required int port, + }) async { + return sendRaw( + address: address, + port: port, + event: 'P_ON', + ); + } + + /// Turns off the lamp. + /// + /// Typically lamp responds to this request with the + /// [GyverLampCurrentResponse]. + /// + /// Throws a [GyverLampResponseParseException] in case of malformed response. + /// Throws a [GyverLampClientException] when data sending or retrieving + /// failed. + Future turnOff({ + required String address, + required int port, + }) async { + return sendRaw( + address: address, + port: port, + event: 'P_OFF', + ); + } + + /// Sends passed [event] to the lamp. + /// + /// Throws a [GyverLampResponseParseException] in case of malformed response. + /// Throws a [GyverLampClientException] when data sending or retrieving + /// failed. + Future sendRaw({ + required String address, + required int port, + required String event, + }) async { + if (isClosed) { + throw GyverLampClientException( + 'Client is closed', + StackTrace.current, + ); + } + + try { + _onSend?.call(address, port, event); + + final response = await _connection.send( + address: address, + port: port, + event: event, + ); + + _onResponse?.call(address, port, response); + + _controller.add(response); + + return response; + } catch (e) { + _onError?.call(address, port, e); + rethrow; + } + } +} diff --git a/packages/gyver_lamp_client/lib/src/gyver_lamp_connection.dart b/packages/gyver_lamp_client/lib/src/gyver_lamp_connection.dart new file mode 100644 index 0000000..eb77392 --- /dev/null +++ b/packages/gyver_lamp_client/lib/src/gyver_lamp_connection.dart @@ -0,0 +1,184 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:gyver_lamp_client/src/src.dart'; + +/// A factory to create and bind a [RawDatagramSocket] instance. +typedef RawDatagramSocketFactory = FutureOr Function( + String host, + int port, +); + +/// {@template gyver_lamp_connection} +/// Connection to the lamp to send and receive data. +/// {@endtemplate} +class GyverLampConnection { + /// {@macro gyver_lamp_connection} + GyverLampConnection({ + RawDatagramSocketFactory socketFactory = RawDatagramSocket.bind, + Duration timeout = const Duration(seconds: 5), + }) : _socketFactory = socketFactory, + _timeout = timeout; + + final RawDatagramSocketFactory _socketFactory; + final Duration _timeout; + + final _requestQueue = Queue Function()>(); + + bool _isProcessingQueue = false; + + Completer _responseCompleter = Completer(); + + RawDatagramSocket? _socket; + StreamSubscription? _subscription; + + /// Whether the connection is closed. + /// + /// When connection is not closed it means that socket is live and listening + /// for an events. + bool get isClosed => _socket == null; + + /// Creates socket, binds it to the local address and port, and listens to the + /// events. + /// + /// Throws a [GyverLampClientException] if something unexpected happens. + Future bind() async { + try { + _socket = await _socketFactory(InternetAddress.anyIPv4.address, 0); + + _subscription = _socket!.listen( + (event) async { + switch (event) { + case RawSocketEvent.closed: + case RawSocketEvent.readClosed: + await close(); + + case RawSocketEvent.read: + _datagramListener( + _socket?.receive(), + ); + + case RawSocketEvent.write: + // do nothing + } + }, + onDone: () async { + await close(); + }, + onError: (Object e, StackTrace t) async { + await close(); + }, + ); + } catch (e, t) { + throw GyverLampClientException(e, t); + } + } + + void _datagramListener(Datagram? datagram) { + try { + if (datagram == null) { + throw GyverLampResponseParseException( + 'Datagram is empty', + StackTrace.current, + ); + } + + final data = utf8.decode(datagram.data); + + _responseCompleter.complete( + parseResponse(data), + ); + } on GyverLampResponseParseException catch (e) { + _responseCompleter.completeError(e); + } catch (e, t) { + _responseCompleter.completeError( + GyverLampClientException(e, t), + ); + } + + _responseCompleter = Completer(); + } + + /// Releases the resources used by the connection. + /// + /// Connection still can be reused by calling [bind] function. + Future close() async { + await _subscription?.cancel(); + _subscription = null; + + _socket?.close(); + _socket = null; + + // prevent potentially open futures from completing after re-connecting + _responseCompleter = Completer(); + } + + /// Sends an [event] to the specified lamp's [address] and [port]. + /// + /// Throws a [GyverLampResponseParseException] in case of malformed response. + /// Throws a [GyverLampClientException] when data sending or retrieving + /// failed. + Future send({ + required String address, + required int port, + required String event, + }) async { + final completer = Completer(); + + _requestQueue.add(() async { + try { + if (isClosed) { + await bind(); + } + + final bytes = _socket?.send( + utf8.encode(event), + InternetAddress(address), + port, + ); + + if (bytes == null || bytes == 0) { + await close(); + + throw GyverLampClientException( + 'Message was not sent', + StackTrace.current, + ); + } + + final response = await _responseCompleter.future.timeout(_timeout); + + completer.complete(response); + } on GyverLampResponseParseException catch (e, t) { + completer.completeError(e, t); + } on GyverLampClientException catch (e, t) { + completer.completeError(e, t); + } catch (e, t) { + completer.completeError( + GyverLampClientException(e, t), + ); + } + + _responseCompleter = Completer(); + }); + + if (!_isProcessingQueue) { + unawaited(_processQueue()); + } + + return completer.future; + } + + Future _processQueue() async { + _isProcessingQueue = true; + + while (_requestQueue.isNotEmpty) { + final request = _requestQueue.removeFirst(); + await request(); + } + + _isProcessingQueue = false; + } +} diff --git a/packages/gyver_lamp_client/lib/src/parser.dart b/packages/gyver_lamp_client/lib/src/parser.dart new file mode 100644 index 0000000..d5fe6ca --- /dev/null +++ b/packages/gyver_lamp_client/lib/src/parser.dart @@ -0,0 +1,83 @@ +import 'package:gyver_lamp_client/src/src.dart'; +import 'package:meta/meta.dart'; + +/// {@template gyver_lamp_response_parse_exception} +/// Exception thrown when lamp response parsing failed. +/// +/// Check [cause] and [stackTrace] for specific details. +/// {@endtemplate} +class GyverLampResponseParseException implements Exception { + /// {@macro gyver_lamp_response_parse_exception} + const GyverLampResponseParseException(this.cause, this.stackTrace); + + /// The cause of the exception. + final dynamic cause; + + /// The stack trace of the exception. + final StackTrace stackTrace; +} + +@visibleForTesting +// ignore: public_member_api_docs +const currentPrefix = 'CURR'; + +@visibleForTesting +// ignore: public_member_api_docs +const brightnessPrefix = 'BRI'; + +@visibleForTesting +// ignore: public_member_api_docs +const speedPrefix = 'SPD'; + +@visibleForTesting +// ignore: public_member_api_docs +const scalePrefix = 'SCA'; + +@visibleForTesting +// ignore: public_member_api_docs +const okPrefix = 'OK'; + +/// Returns an [GyverLampResponse] based on a parsed [data]. +GyverLampResponse parseResponse(String data) { + try { + final values = data.split(' ').skip(1).toList(); + + if (data.startsWith(currentPrefix)) { + return GyverLampCurrentResponse( + mode: int.parse(values[0]), + brightness: int.parse(values[1]), + speed: int.parse(values[2]), + scale: int.parse(values[3]), + isOn: int.parse(values[4]) == 1, + ); + } + + if (data.startsWith(brightnessPrefix)) { + return GyverLampBrightnessResponse( + brightness: int.parse(values[0]), + ); + } + + if (data.startsWith(speedPrefix)) { + return GyverLampSpeedResponse( + speed: int.parse(values[0]), + ); + } + + if (data.startsWith(scalePrefix)) { + return GyverLampScaleResponse( + scale: int.parse(values[0]), + ); + } + + if (data.startsWith(okPrefix)) { + return GyverLampOkResponse( + timestamp: values[0], + ); + } + + return GyverLampUnknownResponse(data: data); + } catch (e, t) { + throw GyverLampResponseParseException(e, t); + } +} diff --git a/packages/gyver_lamp_client/lib/src/response.dart b/packages/gyver_lamp_client/lib/src/response.dart new file mode 100644 index 0000000..0962c5b --- /dev/null +++ b/packages/gyver_lamp_client/lib/src/response.dart @@ -0,0 +1,124 @@ +import 'package:equatable/equatable.dart'; + +/// {@template gyver_lamp_response} +/// The response received from the lamp. +/// {@endtemplate} +sealed class GyverLampResponse extends Equatable { + /// {@macro gyver_lamp_response} + const GyverLampResponse(); + + @override + List get props; +} + +/// {@template gyver_lamp_current_response} +/// The response received from the lamp which provides current state. +/// {@endtemplate} +class GyverLampCurrentResponse extends GyverLampResponse { + /// {@macro gyver_lamp_current_response} + const GyverLampCurrentResponse({ + required this.mode, + required this.brightness, + required this.speed, + required this.scale, + required this.isOn, + }); + + /// The index of the current mode. + final int mode; + + /// The brightness level of the current mode. + final int brightness; + + /// The speed value of the current mode. + final int speed; + + /// The scale value of the current mode. + final int scale; + + /// Whether lamp is enabled. + final bool isOn; + + @override + List get props => [mode, brightness, speed, scale, isOn]; +} + +/// {@template gyver_lamp_ok_response} +/// The response received from the lamp after the ping request. +/// {@endtemplate} +class GyverLampOkResponse extends GyverLampResponse { + /// {@macro gyver_lamp_ok_response} + const GyverLampOkResponse({ + required this.timestamp, + }); + + /// The timestamp when ping request was received by the lamp. + final String timestamp; + + @override + List get props => [timestamp]; +} + +/// {@template gyver_lamp_brightness_response} +/// The response received from the lamp which provides current brightness level. +/// {@endtemplate} +class GyverLampBrightnessResponse extends GyverLampResponse { + /// {@macro gyver_lamp_brightness_response} + const GyverLampBrightnessResponse({ + required this.brightness, + }); + + /// The brightness level. + final int brightness; + + @override + List get props => [brightness]; +} + +/// {@template gyver_lamp_speed_response} +/// The response received from the lamp which provides current speed value. +/// {@endtemplate} +class GyverLampSpeedResponse extends GyverLampResponse { + /// {@macro gyver_lamp_speed_response} + const GyverLampSpeedResponse({ + required this.speed, + }); + + /// The speed value. + final int speed; + + @override + List get props => [speed]; +} + +/// {@template gyver_lamp_scale_response} +/// The response received from the lamp which provides current scale value. +/// {@endtemplate} +class GyverLampScaleResponse extends GyverLampResponse { + /// {@macro gyver_lamp_scale_response} + const GyverLampScaleResponse({ + required this.scale, + }); + + /// The scale value. + final int scale; + + @override + List get props => [scale]; +} + +/// {@template gyver_lamp_unknown_response} +/// The not recognized response received from the lamp. +/// {@endtemplate} +class GyverLampUnknownResponse extends GyverLampResponse { + /// {@macro gyver_lamp_unknown_response} + const GyverLampUnknownResponse({ + required this.data, + }); + + /// Original (non-parsed) data from the response. + final String data; + + @override + List get props => [data]; +} diff --git a/packages/gyver_lamp_client/lib/src/src.dart b/packages/gyver_lamp_client/lib/src/src.dart new file mode 100644 index 0000000..903f0f8 --- /dev/null +++ b/packages/gyver_lamp_client/lib/src/src.dart @@ -0,0 +1,4 @@ +export 'gyver_lamp_client.dart'; +export 'gyver_lamp_connection.dart'; +export 'parser.dart'; +export 'response.dart'; diff --git a/packages/gyver_lamp_client/pubspec.yaml b/packages/gyver_lamp_client/pubspec.yaml new file mode 100644 index 0000000..a31800e --- /dev/null +++ b/packages/gyver_lamp_client/pubspec.yaml @@ -0,0 +1,17 @@ +name: gyver_lamp_client +description: Client to communicate with the Gyver Lamp. +version: 1.0.0 +publish_to: none + +environment: + sdk: ">=3.0.5 <4.0.0" + +dependencies: + collection: ^1.17.2 + equatable: ^2.0.5 + meta: ^1.9.1 + +dev_dependencies: + mocktail: 1.0.0 + test: 1.24.6 + very_good_analysis: 5.1.0 diff --git a/packages/gyver_lamp_client/test/gyver_lamp_client_test.dart b/packages/gyver_lamp_client/test/gyver_lamp_client_test.dart new file mode 100644 index 0000000..60c11cc --- /dev/null +++ b/packages/gyver_lamp_client/test/gyver_lamp_client_test.dart @@ -0,0 +1,945 @@ +import 'package:gyver_lamp_client/src/src.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockGyverLampConnection extends Mock implements GyverLampConnection {} + +class _MockGyverLampConnectionFactory extends Mock { + GyverLampConnection call({ + RawDatagramSocketFactory socketFactory, + Duration timeout, + }); +} + +void main() { + setUpAll(() { + registerFallbackValue(Duration.zero); + }); + + group('GyverLampClient', () { + const address = '192.168.1.1'; + const port = 8888; + + // ignore: prefer_const_constructors + final expectedResponse = GyverLampOkResponse(timestamp: '11.22.63'); + + late GyverLampClient subject; + late GyverLampConnection connection; + late GyverLampConnectionFactory connectionFactory; + + setUp(() { + connection = _MockGyverLampConnection(); + + when(connection.close).thenAnswer((_) async {}); + + connectionFactory = _MockGyverLampConnectionFactory().call; + + when( + () => connectionFactory( + socketFactory: any(named: 'socketFactory'), + timeout: any(named: 'timeout'), + ), + ).thenReturn(connection); + + when( + () => connection.send( + address: any(named: 'address'), + port: any(named: 'port'), + event: any(named: 'event'), + ), + ).thenAnswer( + (_) async => expectedResponse, + ); + + subject = GyverLampClient(connectionFactory: connectionFactory); + }); + + tearDown(() async { + await subject.close(); + }); + + test('can be instantiated', () { + expect( + GyverLampClient(connectionFactory: connectionFactory), + isNotNull, + ); + }); + + group('close', () { + test('calls connection close', () async { + await subject.close(); + verify(connection.close).called(1); + }); + + test('closes the client', () async { + expect(subject.isClosed, isFalse); + + await subject.close(); + + expect(subject.isClosed, isTrue); + }); + }); + + group('getCurrentState', () { + test('returns the response', () async { + final response = await subject.getCurrentState( + address: address, + port: port, + ); + + expect( + response, + equals(expectedResponse), + ); + }); + + test('sends the request correctly', () async { + await subject.getCurrentState( + address: address, + port: port, + ); + + verify( + () => connection.send( + address: address, + port: port, + event: 'GET', + ), + ).called(1); + }); + + test('throws a GyverLampClientException when client is closed', () async { + await subject.close(); + + await expectLater( + () => subject.getCurrentState( + address: address, + port: port, + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals('Client is closed'), + ), + ), + ); + }); + }); + + group('ping', () { + test('returns the response', () async { + final response = await subject.ping( + address: address, + port: port, + ); + + expect( + response, + equals(expectedResponse), + ); + }); + + test('sends the request correctly', () async { + await subject.ping( + address: address, + port: port, + ); + + verify( + () => connection.send( + address: address, + port: port, + event: 'DEB', + ), + ).called(1); + }); + + test('throws a GyverLampClientException when client is closed', () async { + await subject.close(); + + await expectLater( + () => subject.ping( + address: address, + port: port, + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals('Client is closed'), + ), + ), + ); + }); + }); + + group('setMode', () { + test('returns the response', () async { + final response = await subject.setMode( + address: address, + port: port, + mode: 3, + ); + + expect( + response, + equals(expectedResponse), + ); + }); + + test('sends the request correctly', () async { + await subject.setMode( + address: address, + port: port, + mode: 3, + ); + + verify( + () => connection.send( + address: address, + port: port, + event: 'EFF 3', + ), + ).called(1); + }); + + test('throws a GyverLampClientException when client is closed', () async { + await subject.close(); + + await expectLater( + () => subject.setMode( + address: address, + port: port, + mode: 3, + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals('Client is closed'), + ), + ), + ); + }); + + test('throws AssertionError when mode is less than 0', () async { + await expectLater( + () => subject.setMode( + address: address, + port: port, + mode: -1, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals( + 'the mode value range is [0, 17]', + ), + ), + ), + ); + }); + + test('throws AssertionError when mode is greater than 17', () async { + await expectLater( + () => subject.setMode( + address: address, + port: port, + mode: 18, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals( + 'the mode value range is [0, 17]', + ), + ), + ), + ); + }); + }); + + group('setBrightness', () { + test('returns the response', () async { + final response = await subject.setBrightness( + address: address, + port: port, + brightness: 33, + ); + + expect( + response, + equals(expectedResponse), + ); + }); + + test('sends the request correctly', () async { + await subject.setBrightness( + address: address, + port: port, + brightness: 33, + ); + + verify( + () => connection.send( + address: address, + port: port, + event: 'BRI 33', + ), + ).called(1); + }); + + test('throws a GyverLampClientException when client is closed', () async { + await subject.close(); + + await expectLater( + () => subject.setBrightness( + address: address, + port: port, + brightness: 33, + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals('Client is closed'), + ), + ), + ); + }); + + test('throws AssertionError when brightness is less than 1', () async { + await expectLater( + () => subject.setBrightness( + address: address, + port: port, + brightness: 0, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals( + 'the brightness value range is [1, 255]', + ), + ), + ), + ); + }); + + test( + 'throws AssertionError ' + 'when brightness is greater than 255', () async { + await expectLater( + () => subject.setBrightness( + address: address, + port: port, + brightness: 256, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals( + 'the brightness value range is [1, 255]', + ), + ), + ), + ); + }); + }); + + group('setSpeed', () { + test('returns the response', () async { + final response = await subject.setSpeed( + address: address, + port: port, + speed: 33, + ); + + expect( + response, + equals(expectedResponse), + ); + }); + + test('sends the request correctly', () async { + await subject.setSpeed( + address: address, + port: port, + speed: 33, + ); + + verify( + () => connection.send( + address: address, + port: port, + event: 'SPD 33', + ), + ).called(1); + }); + + test('throws a GyverLampClientException when client is closed', () async { + await subject.close(); + + await expectLater( + () => subject.setSpeed( + address: address, + port: port, + speed: 33, + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals('Client is closed'), + ), + ), + ); + }); + + test('throws AssertionError when speed is less than 1', () async { + await expectLater( + () => subject.setSpeed( + address: address, + port: port, + speed: 0, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals( + 'the speed value range is [1, 255]', + ), + ), + ), + ); + }); + + test( + 'throws AssertionError ' + 'when speed is greater than 255', () async { + await expectLater( + () => subject.setSpeed( + address: address, + port: port, + speed: 256, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals( + 'the speed value range is [1, 255]', + ), + ), + ), + ); + }); + }); + + group('setScale', () { + test('returns the response', () async { + final response = await subject.setScale( + address: address, + port: port, + scale: 33, + ); + + expect( + response, + equals(expectedResponse), + ); + }); + + test('sends the request correctly', () async { + await subject.setScale( + address: address, + port: port, + scale: 33, + ); + + verify( + () => connection.send( + address: address, + port: port, + event: 'SCA 33', + ), + ).called(1); + }); + + test('throws a GyverLampClientException when client is closed', () async { + await subject.close(); + + await expectLater( + () => subject.setScale( + address: address, + port: port, + scale: 33, + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals('Client is closed'), + ), + ), + ); + }); + + test('throws AssertionError when scale is less than 1', () async { + await expectLater( + () => subject.setScale( + address: address, + port: port, + scale: 0, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals( + 'the scale value range is [1, 255]', + ), + ), + ), + ); + }); + + test( + 'throws AssertionError ' + 'when scale is greater than 255', () async { + await expectLater( + () => subject.setScale( + address: address, + port: port, + scale: 256, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals( + 'the scale value range is [1, 255]', + ), + ), + ), + ); + }); + }); + + group('turnOn', () { + test('returns the response', () async { + final response = await subject.turnOn( + address: address, + port: port, + ); + + expect( + response, + equals(expectedResponse), + ); + }); + + test('sends the request correctly', () async { + await subject.turnOn( + address: address, + port: port, + ); + + verify( + () => connection.send( + address: address, + port: port, + event: 'P_ON', + ), + ).called(1); + }); + + test('throws a GyverLampClientException when client is closed', () async { + await subject.close(); + + await expectLater( + () => subject.turnOn( + address: address, + port: port, + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals('Client is closed'), + ), + ), + ); + }); + }); + + group('turnOff', () { + test('returns the response', () async { + final response = await subject.turnOff( + address: address, + port: port, + ); + + expect( + response, + equals(expectedResponse), + ); + }); + + test('sends the request correctly', () async { + await subject.turnOff( + address: address, + port: port, + ); + + verify( + () => connection.send( + address: address, + port: port, + event: 'P_OFF', + ), + ).called(1); + }); + + test('throws a GyverLampClientException when client is closed', () async { + await subject.close(); + + await expectLater( + () => subject.turnOff( + address: address, + port: port, + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals('Client is closed'), + ), + ), + ); + }); + }); + + group('sendRaw', () { + test('returns the response', () async { + final response = await subject.sendRaw( + address: address, + port: port, + event: 'TEST', + ); + + expect( + response, + equals(expectedResponse), + ); + }); + + test('sends the request correctly', () async { + await subject.sendRaw( + address: address, + port: port, + event: 'TEST', + ); + + verify( + () => connection.send( + address: address, + port: port, + event: 'TEST', + ), + ).called(1); + }); + + test('throws a GyverLampClientException when client is closed', () async { + await subject.close(); + + await expectLater( + () => subject.sendRaw( + address: address, + port: port, + event: 'TEST', + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals('Client is closed'), + ), + ), + ); + }); + + test('calls onSend callback', () async { + String? loggedAddress; + int? loggedPort; + Object? loggedData; + + final subject = GyverLampClient( + connectionFactory: connectionFactory, + onSend: (address, port, data) { + loggedAddress = address; + loggedPort = port; + loggedData = data; + }, + ); + + await subject.sendRaw( + address: address, + port: port, + event: 'TEST', + ); + await subject.close(); + + expect(loggedAddress, equals(address)); + expect(loggedPort, equals(port)); + expect(loggedData, equals('TEST')); + }); + + test('calls onResponse callback', () async { + String? loggedAddress; + int? loggedPort; + Object? loggedData; + + final subject = GyverLampClient( + connectionFactory: connectionFactory, + onResponse: (address, port, data) { + loggedAddress = address; + loggedPort = port; + loggedData = data; + }, + ); + + final response = await subject.sendRaw( + address: address, + port: port, + event: 'TEST', + ); + await subject.close(); + + expect(loggedAddress, equals(address)); + expect(loggedPort, equals(port)); + expect(loggedData, equals(expectedResponse)); + expect(loggedData, equals(response)); + }); + + test('calls onError callback', () async { + when( + () => connection.send( + address: any(named: 'address'), + port: any(named: 'port'), + event: any(named: 'event'), + ), + ).thenThrow( + GyverLampClientException( + ArgumentError('ERROR'), + StackTrace.empty, + ), + ); + + String? loggedAddress; + int? loggedPort; + Object? loggedData; + + final subject = GyverLampClient( + connectionFactory: connectionFactory, + onError: (address, port, data) { + loggedAddress = address; + loggedPort = port; + loggedData = data; + }, + ); + + await expectLater( + () => subject.sendRaw( + address: address, + port: port, + event: 'TEST', + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + isA().having( + (e) => e.message, + 'message', + 'ERROR', + ), + ), + ), + ); + await subject.close(); + + expect(loggedAddress, equals(address)); + expect(loggedPort, equals(port)); + expect( + loggedData, + isA().having( + (e) => e.cause, + 'cause', + isA().having( + (e) => e.message, + 'message', + 'ERROR', + ), + ), + ); + }); + }); + + group('responses', () { + test('emits response of getCurrentState', () async { + subject.responses.listen((event) { + expectAsync1( + (response) { + expect( + response, + equals(expectedResponse), + ); + }, + ); + }); + + await subject.getCurrentState( + address: address, + port: port, + ); + }); + + test('emits response of ping', () async { + subject.responses.listen((event) { + expectAsync1( + (response) { + expect( + response, + equals(expectedResponse), + ); + }, + ); + }); + + await subject.ping( + address: address, + port: port, + ); + }); + + test('emits response of setMode', () async { + subject.responses.listen((event) { + expectAsync1( + (response) { + expect( + response, + equals(expectedResponse), + ); + }, + ); + }); + + await subject.setMode( + address: address, + port: port, + mode: 3, + ); + }); + + test('emits response of setBrightness', () async { + subject.responses.listen((event) { + expectAsync1( + (response) { + expect( + response, + equals(expectedResponse), + ); + }, + ); + }); + + await subject.setBrightness( + address: address, + port: port, + brightness: 3, + ); + }); + + test('emits response of setSpeed', () async { + subject.responses.listen((event) { + expectAsync1( + (response) { + expect( + response, + equals(expectedResponse), + ); + }, + ); + }); + + await subject.setSpeed( + address: address, + port: port, + speed: 3, + ); + }); + + test('emits response of setScale', () async { + subject.responses.listen((event) { + expectAsync1( + (response) { + expect( + response, + equals(expectedResponse), + ); + }, + ); + }); + + await subject.setScale( + address: address, + port: port, + scale: 3, + ); + }); + + test('emits response of turnOn', () async { + subject.responses.listen((event) { + expectAsync1( + (response) { + expect( + response, + equals(expectedResponse), + ); + }, + ); + }); + + await subject.turnOn( + address: address, + port: port, + ); + }); + + test('emits response of turnOff', () async { + subject.responses.listen((event) { + expectAsync1( + (response) { + expect( + response, + equals(expectedResponse), + ); + }, + ); + }); + + await subject.turnOff( + address: address, + port: port, + ); + }); + }); + }); +} diff --git a/packages/gyver_lamp_client/test/gyver_lamp_connection_test.dart b/packages/gyver_lamp_client/test/gyver_lamp_connection_test.dart new file mode 100644 index 0000000..fe8c380 --- /dev/null +++ b/packages/gyver_lamp_client/test/gyver_lamp_connection_test.dart @@ -0,0 +1,601 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:gyver_lamp_client/src/src.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockRawDatagramSocket extends Mock implements RawDatagramSocket {} + +class _MockRawDatagramSocketFactory extends Mock { + FutureOr call( + String host, + int port, + ); +} + +void main() { + setUpAll(() { + registerFallbackValue(InternetAddress.tryParse('0.0.0.0')); + }); + + group('GyverLampConnection', () { + const address = '192.168.1.1'; + const port = 8888; + const timeout = Duration(milliseconds: 200); + + final debugDatagram = Datagram( + Uint8List.fromList(utf8.encode('OK 11.22.63')), + InternetAddress(address), + port, + ); + + // ignore: prefer_const_constructors + final debugResponse = GyverLampOkResponse(timestamp: '11.22.63'); + + late GyverLampConnection subject; + late RawDatagramSocket socket; + late RawDatagramSocketFactory socketFactory; + late StreamController eventsStreamController; + + setUp(() { + eventsStreamController = StreamController.broadcast(); + + socket = _MockRawDatagramSocket(); + + when( + () => socket.listen( + any(), + onDone: any(named: 'onDone'), + onError: any(named: 'onError'), + cancelOnError: any(named: 'cancelOnError'), + ), + ).thenAnswer( + (invocation) { + final positional = invocation.positionalArguments; + final named = invocation.namedArguments; + + return eventsStreamController.stream.listen( + (event) async { + final closure = + positional.first as FutureOr Function(RawSocketEvent); + await closure(event); + }, + onDone: named[#onDone] as void Function()?, + onError: named[#onError] as void Function(Object, StackTrace)?, + cancelOnError: named[#cancelOnError] as bool? ?? false, + ); + }, + ); + + when( + () => socket.send(any(), any(), any()), + ).thenAnswer( + (invocation) { + final positional = invocation.positionalArguments; + return (positional.first as List).length; + }, + ); + + when(socket.receive).thenReturn(debugDatagram); + + when(socket.close).thenAnswer((_) {}); + + socketFactory = _MockRawDatagramSocketFactory().call; + + when( + () => socketFactory(any(), any()), + ).thenAnswer( + (_) async => socket, + ); + + subject = GyverLampConnection( + socketFactory: socketFactory, + timeout: timeout, + ); + }); + + tearDown(() { + eventsStreamController.close(); + subject.close(); + }); + + test('can be instantiated', () { + expect( + GyverLampConnection(socketFactory: socketFactory), + isNotNull, + ); + }); + + group('isClosed', () { + test('true by default', () { + expect( + GyverLampConnection(socketFactory: socketFactory), + isA().having( + (c) => c.isClosed, + 'isClosed', + isTrue, + ), + ); + }); + + test('false after binding', () async { + await subject.bind(); + + expect( + subject.isClosed, + isFalse, + ); + }); + + test('true after binding and closing', () async { + await subject.bind(); + await subject.close(); + + expect( + subject.isClosed, + isTrue, + ); + }); + }); + + group('bind', () { + test('calls factory to create new socket', () async { + await subject.bind(); + + verify( + () => socketFactory.call( + any( + that: isA().having( + (a) => RegExp(r'\d+.\d+.\d+.\d+').hasMatch(a), + 'IPv4 format', + isTrue, + ), + ), + 0, + ), + ).called(1); + }); + + test('subscribes to the events stream', () async { + await subject.bind(); + + verify( + () => socket.listen( + any(), + onDone: any(named: 'onDone'), + onError: any(named: 'onError'), + cancelOnError: any(named: 'cancelOnError'), + ), + ).called(1); + }); + + test('throws GyverLampClientException when exception happens', () async { + when( + () => socketFactory(any(), any()), + ).thenThrow( + ArgumentError('TEST'), + ); + + await expectLater( + () => subject.bind(), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + isA().having( + (e) => e.message, + 'message', + equals( + 'TEST', + ), + ), + ), + ), + ); + }); + + test('calls close when the events stream is done', () async { + await subject.bind(); + + expect( + subject.isClosed, + isFalse, + ); + + await eventsStreamController.close(); + + await Future.delayed( + const Duration(milliseconds: 20), + ); + + verify( + () => socket.close(), + ).called(1); + + expect( + subject.isClosed, + isTrue, + ); + }); + + test('calls close when the events stream has error', () async { + await subject.bind(); + + expect( + subject.isClosed, + isFalse, + ); + + eventsStreamController.addError(TimeoutException('TEST')); + + await Future.delayed( + const Duration(milliseconds: 20), + ); + + verify( + () => socket.close(), + ).called(1); + + expect( + subject.isClosed, + isTrue, + ); + }); + + test('calls close when the events stream has closed event', () async { + await subject.bind(); + + expect( + subject.isClosed, + isFalse, + ); + + eventsStreamController.add(RawSocketEvent.closed); + + await Future.delayed( + const Duration(milliseconds: 20), + ); + + verify( + () => socket.close(), + ).called(1); + + expect( + subject.isClosed, + isTrue, + ); + }); + + test('calls close when the events stream has readClosed event', () async { + await subject.bind(); + + expect( + subject.isClosed, + isFalse, + ); + + eventsStreamController.add(RawSocketEvent.readClosed); + + await Future.delayed( + const Duration(milliseconds: 20), + ); + + verify( + () => socket.close(), + ).called(1); + + expect( + subject.isClosed, + isTrue, + ); + }); + + test( + 'does not call close ' + 'when the events stream has write event', () async { + await subject.bind(); + + expect( + subject.isClosed, + isFalse, + ); + + eventsStreamController.add(RawSocketEvent.write); + + await Future.delayed( + const Duration(milliseconds: 20), + ); + + verifyNever( + () => socket.close(), + ); + + expect( + subject.isClosed, + isFalse, + ); + }); + }); + + group('close', () { + test('closes the connection', () async { + await subject.bind(); + await subject.close(); + + verify( + () => socket.close(), + ).called(1); + + expect( + subject.isClosed, + isTrue, + ); + }); + }); + + group('send', () { + test( + 'creates socket and subscribes to the events ' + 'when connection is closed', () async { + expect( + subject.isClosed, + isTrue, + ); + + eventsStreamController.onListen = () { + eventsStreamController.sink.add(RawSocketEvent.read); + }; + + await subject.send( + address: address, + port: port, + event: 'DEB', + ); + + verify( + () => socket.send( + utf8.encode('DEB'), + InternetAddress(address), + port, + ), + ).called(1); + + verify( + () => socketFactory.call( + any( + that: isA().having( + (a) => RegExp(r'\d+.\d+.\d+.\d+').hasMatch(a), + 'IPv4 format', + isTrue, + ), + ), + 0, + ), + ).called(1); + + verify( + () => socket.listen( + any(), + onDone: any(named: 'onDone'), + onError: any(named: 'onError'), + cancelOnError: any(named: 'cancelOnError'), + ), + ).called(1); + + expect( + subject.isClosed, + isFalse, + ); + }); + + test('returns response when everything is ok', () async { + eventsStreamController.onListen = () { + eventsStreamController.sink.add(RawSocketEvent.read); + }; + + final response = await subject.send( + address: address, + port: port, + event: 'DEB', + ); + + expect( + response, + equals(debugResponse), + ); + }); + + test( + 'throws GyverLampClientException and calls close ' + 'when send is not successful', () async { + when( + () => socket.send(any(), any(), any()), + ).thenReturn(0); + + eventsStreamController.onListen = () { + eventsStreamController.sink.add(RawSocketEvent.read); + }; + + await expectLater( + () => subject.send( + address: address, + port: port, + event: 'DEB', + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals( + 'Message was not sent', + ), + ), + ), + ); + + verify( + () => socket.close(), + ).called(1); + + expect( + subject.isClosed, + isTrue, + ); + }); + + test( + 'throws GyverLampResponseParseException ' + 'when datagram is not present', () async { + when( + () => socket.receive(), + ).thenReturn(null); + + eventsStreamController.onListen = () { + eventsStreamController.sink.add(RawSocketEvent.read); + }; + + await expectLater( + () => subject.send( + address: address, + port: port, + event: 'DEB', + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + equals( + 'Datagram is empty', + ), + ), + ), + ); + }); + + test( + 'throws GyverLampResponseParseException ' + 'when response format is wrong', () async { + when( + () => socket.receive(), + ).thenReturn( + Datagram( + Uint8List.fromList(utf8.encode('OK11.22.63')), + InternetAddress(address), + port, + ), + ); + + eventsStreamController.onListen = () { + eventsStreamController.sink.add(RawSocketEvent.read); + }; + + await expectLater( + () => subject.send( + address: address, + port: port, + event: 'DEB', + ), + throwsA( + isA(), + ), + ); + }); + + test( + 'throws GyverLampClientException ' + 'when response is malformed', () async { + when( + () => socket.receive(), + ).thenReturn( + Datagram( + Uint8List.fromList('©'.codeUnits), + InternetAddress(address), + port, + ), + ); + + eventsStreamController.onListen = () { + eventsStreamController.sink.add(RawSocketEvent.read); + }; + + await expectLater( + () => subject.send( + address: address, + port: port, + event: 'DEB', + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + isA().having( + (e) => e.message, + 'message', + equals( + 'Unexpected extension byte', + ), + ), + ), + ), + ); + }); + + test( + 'throws GyverLampClientException ' + 'when client is closed during the datagram process', () async { + eventsStreamController.onListen = () async { + await subject.close(); + }; + + await expectLater( + () => subject.send( + address: address, + port: port, + event: 'DEB', + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + isA().having( + (e) => e.message, + 'message', + equals( + 'Future not completed', + ), + ), + ), + ), + ); + }); + + test( + 'throws GyverLampClientException ' + 'when no response from the lamp causes timeout', () async { + await expectLater( + () => subject.send( + address: address, + port: port, + event: 'DEB', + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + isA().having( + (e) => e.message, + 'message', + equals( + 'Future not completed', + ), + ), + ), + ), + ); + }); + }); + }); +} diff --git a/packages/gyver_lamp_client/test/parser_test.dart b/packages/gyver_lamp_client/test/parser_test.dart new file mode 100644 index 0000000..d1963f6 --- /dev/null +++ b/packages/gyver_lamp_client/test/parser_test.dart @@ -0,0 +1,134 @@ +import 'package:gyver_lamp_client/src/src.dart'; +import 'package:test/test.dart'; + +void main() { + group('parseResponse', () { + test('can parse GyverLampCurrentResponse', () { + expect( + parseResponse('$currentPrefix 1 2 3 4 0'), + equals( + const GyverLampCurrentResponse( + mode: 1, + brightness: 2, + speed: 3, + scale: 4, + isOn: false, + ), + ), + ); + + expect( + parseResponse('$currentPrefix 1 2 3 4 1'), + equals( + const GyverLampCurrentResponse( + mode: 1, + brightness: 2, + speed: 3, + scale: 4, + isOn: true, + ), + ), + ); + }); + + test( + 'throws GyverLampResponseParseException ' + 'when GyverLampCurrentResponse is malformed', () { + expect( + () => parseResponse('$currentPrefix xxx 2 3 4 0'), + throwsA( + isA(), + ), + ); + }); + + test('can parse GyverLampOkResponse', () { + expect( + parseResponse('$okPrefix 11.22.63'), + equals( + const GyverLampOkResponse(timestamp: '11.22.63'), + ), + ); + }); + + test( + 'throws GyverLampResponseParseException ' + 'when GyverLampOkResponse is malformed', () { + expect( + () => parseResponse(okPrefix), + throwsA( + isA(), + ), + ); + }); + + test('can parse GyverLampBrightnessResponse', () { + expect( + parseResponse('$brightnessPrefix 1'), + equals( + const GyverLampBrightnessResponse(brightness: 1), + ), + ); + }); + + test( + 'throws GyverLampResponseParseException ' + 'when GyverLampBrightnessResponse is malformed', () { + expect( + () => parseResponse('$brightnessPrefix xxx'), + throwsA( + isA(), + ), + ); + }); + + test('can parse GyverLampSpeedResponse', () { + expect( + parseResponse('$speedPrefix 1'), + equals( + const GyverLampSpeedResponse(speed: 1), + ), + ); + }); + + test( + 'throws GyverLampResponseParseException ' + 'when GyverLampSpeedResponse is malformed', () { + expect( + () => parseResponse('$speedPrefix xxx'), + throwsA( + isA(), + ), + ); + }); + + test('can parse GyverLampScaleResponse', () { + expect( + parseResponse('$scalePrefix 1'), + equals( + const GyverLampScaleResponse(scale: 1), + ), + ); + }); + + test( + 'throws GyverLampResponseParseException ' + 'when GyverLampScaleResponse is malformed', () { + expect( + () => parseResponse('$scalePrefix xxx'), + throwsA( + isA(), + ), + ); + }); + + test( + 'returns GyverLampUnknownResponse ' + 'when unknown prefix is specified', () { + expect( + parseResponse('X 1'), + const GyverLampUnknownResponse(data: 'X 1'), + ); + }); + }); +} diff --git a/packages/gyver_lamp_client/test/response_test.dart b/packages/gyver_lamp_client/test/response_test.dart new file mode 100644 index 0000000..e694875 --- /dev/null +++ b/packages/gyver_lamp_client/test/response_test.dart @@ -0,0 +1,272 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:gyver_lamp_client/gyver_lamp_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('GyverLampCurrentResponse', () { + test('can be instantiated', () { + expect( + GyverLampCurrentResponse( + mode: 1, + brightness: 2, + speed: 3, + scale: 4, + isOn: true, + ), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + GyverLampCurrentResponse( + mode: 1, + brightness: 2, + speed: 3, + scale: 4, + isOn: true, + ), + equals( + GyverLampCurrentResponse( + mode: 1, + brightness: 2, + speed: 3, + scale: 4, + isOn: true, + ), + ), + ); + + expect( + GyverLampCurrentResponse( + mode: 1, + brightness: 2, + speed: 3, + scale: 4, + isOn: true, + ), + isNot( + equals( + GyverLampCurrentResponse( + mode: 11, + brightness: 2, + speed: 3, + scale: 4, + isOn: true, + ), + ), + ), + ); + + expect( + GyverLampCurrentResponse( + mode: 1, + brightness: 2, + speed: 3, + scale: 4, + isOn: true, + ), + isNot( + equals( + GyverLampCurrentResponse( + mode: 1, + brightness: 22, + speed: 3, + scale: 4, + isOn: true, + ), + ), + ), + ); + + expect( + GyverLampCurrentResponse( + mode: 1, + brightness: 2, + speed: 3, + scale: 4, + isOn: true, + ), + isNot( + equals( + GyverLampCurrentResponse( + mode: 1, + brightness: 2, + speed: 33, + scale: 4, + isOn: true, + ), + ), + ), + ); + + expect( + GyverLampCurrentResponse( + mode: 1, + brightness: 2, + speed: 3, + scale: 4, + isOn: true, + ), + isNot( + equals( + GyverLampCurrentResponse( + mode: 1, + brightness: 2, + speed: 3, + scale: 44, + isOn: true, + ), + ), + ), + ); + + expect( + GyverLampCurrentResponse( + mode: 1, + brightness: 2, + speed: 3, + scale: 4, + isOn: true, + ), + isNot( + equals( + GyverLampCurrentResponse( + mode: 1, + brightness: 2, + speed: 3, + scale: 4, + isOn: false, + ), + ), + ), + ); + }); + }); + + group('GyverLampOkResponse', () { + test('can be instantiated', () { + expect( + GyverLampOkResponse(timestamp: '11.22.63'), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + GyverLampOkResponse(timestamp: '11.22.63'), + GyverLampOkResponse(timestamp: '11.22.63'), + ); + + expect( + GyverLampOkResponse(timestamp: '11.22.63'), + isNot( + equals( + GyverLampOkResponse(timestamp: '11.22.64'), + ), + ), + ); + }); + }); + + group('GyverLampBrightnessResponse', () { + test('can be instantiated', () { + expect( + GyverLampBrightnessResponse(brightness: 1), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + GyverLampBrightnessResponse(brightness: 1), + GyverLampBrightnessResponse(brightness: 1), + ); + + expect( + GyverLampBrightnessResponse(brightness: 1), + isNot( + equals( + GyverLampBrightnessResponse(brightness: 2), + ), + ), + ); + }); + }); + + group('GyverLampSpeedResponse', () { + test('can be instantiated', () { + expect( + GyverLampSpeedResponse(speed: 1), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + GyverLampSpeedResponse(speed: 1), + GyverLampSpeedResponse(speed: 1), + ); + + expect( + GyverLampSpeedResponse(speed: 1), + isNot( + equals( + GyverLampSpeedResponse(speed: 2), + ), + ), + ); + }); + }); + + group('GyverLampScaleResponse', () { + test('can be instantiated', () { + expect( + GyverLampScaleResponse(scale: 1), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + GyverLampScaleResponse(scale: 1), + GyverLampScaleResponse(scale: 1), + ); + + expect( + GyverLampScaleResponse(scale: 1), + isNot( + equals( + GyverLampScaleResponse(scale: 2), + ), + ), + ); + }); + }); + + group('GyverLampUnknownResponse', () { + test('can be instantiated', () { + expect( + GyverLampUnknownResponse(data: 'data'), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + GyverLampUnknownResponse(data: 'data'), + GyverLampUnknownResponse(data: 'data'), + ); + + expect( + GyverLampUnknownResponse(data: 'data'), + isNot( + equals( + GyverLampUnknownResponse(data: 'data-data'), + ), + ), + ); + }); + }); +} diff --git a/packages/gyver_lamp_effects/.gitignore b/packages/gyver_lamp_effects/.gitignore new file mode 100644 index 0000000..8796d1b --- /dev/null +++ b/packages/gyver_lamp_effects/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VSCode related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +pubspec.lock +macos +.metadata + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Test related +coverage \ No newline at end of file diff --git a/packages/gyver_lamp_effects/README.md b/packages/gyver_lamp_effects/README.md new file mode 100644 index 0000000..857f810 --- /dev/null +++ b/packages/gyver_lamp_effects/README.md @@ -0,0 +1,13 @@ +# Gyver Lamp Effects + +[![coverage][coverage_badge]][coverage_badge] +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +Gyver Lamp Effects visualizations. + +[coverage_badge]: https://raw.githubusercontent.com/VeryGoodOpenSource/very_good_cli/main/coverage_badge.svg +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT \ No newline at end of file diff --git a/packages/gyver_lamp_effects/analysis_options.yaml b/packages/gyver_lamp_effects/analysis_options.yaml new file mode 100644 index 0000000..84e34fb --- /dev/null +++ b/packages/gyver_lamp_effects/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.4.0.0.yaml diff --git a/packages/gyver_lamp_effects/coverage_badge.svg b/packages/gyver_lamp_effects/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/packages/gyver_lamp_effects/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/gyver_lamp_effects/gallery/.gitignore b/packages/gyver_lamp_effects/gallery/.gitignore new file mode 100644 index 0000000..cf0bc35 --- /dev/null +++ b/packages/gyver_lamp_effects/gallery/.gitignore @@ -0,0 +1,47 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +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 +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +android/ +ios/ +macos/ + +# 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/packages/gyver_lamp_effects/gallery/README.md b/packages/gyver_lamp_effects/gallery/README.md new file mode 100644 index 0000000..0fdbda8 --- /dev/null +++ b/packages/gyver_lamp_effects/gallery/README.md @@ -0,0 +1,16 @@ +# gallery + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/gyver_lamp_effects/gallery/analysis_options.yaml b/packages/gyver_lamp_effects/gallery/analysis_options.yaml new file mode 100644 index 0000000..85629ea --- /dev/null +++ b/packages/gyver_lamp_effects/gallery/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:very_good_analysis/analysis_options.4.0.0.yaml + +linter: + rules: + public_member_api_docs: false diff --git a/packages/gyver_lamp_effects/gallery/lib/app.dart b/packages/gyver_lamp_effects/gallery/lib/app.dart new file mode 100644 index 0000000..d3e9f88 --- /dev/null +++ b/packages/gyver_lamp_effects/gallery/lib/app.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/effect.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class App extends StatelessWidget { + const App({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: const Scaffold( + body: Center( + child: SingleChildScrollView( + child: Effect(), + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_effects/gallery/lib/effect.dart b/packages/gyver_lamp_effects/gallery/lib/effect.dart new file mode 100644 index 0000000..ac873a5 --- /dev/null +++ b/packages/gyver_lamp_effects/gallery/lib/effect.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/gyver_lamp_effects.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class Effect extends StatefulWidget { + const Effect({super.key}); + + @override + State createState() => _EffectState(); +} + +class _EffectState extends State { + static const _types = [ + GyverLampEffectType.sparkles, + GyverLampEffectType.fire, + GyverLampEffectType.verticalRainbow, + GyverLampEffectType.horizontalRainbow, + GyverLampEffectType.colors, + GyverLampEffectType.madness, + GyverLampEffectType.clouds, + GyverLampEffectType.lava, + GyverLampEffectType.plasma, + GyverLampEffectType.rainbow, + GyverLampEffectType.rainbowStripes, + GyverLampEffectType.zebra, + GyverLampEffectType.forest, + GyverLampEffectType.ocean, + GyverLampEffectType.color, + GyverLampEffectType.snow, + GyverLampEffectType.matrix, + GyverLampEffectType.fireflies, + ]; + + var _index = 0; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(60), + child: AspectRatio( + aspectRatio: 1, + child: GyverLampEffect( + type: _types[_index], + speed: 30, + scale: 20, + ), + ), + ), + GyverLampGaps.lg, + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RoundedElevatedButton.medium( + child: const Text('<'), + onPressed: () { + setState(() { + _index = (_index - 1) % _types.length; + }); + }, + ), + GyverLampGaps.lg, + RoundedElevatedButton.medium( + child: const Text('>'), + onPressed: () { + setState(() { + _index = (_index + 1) % _types.length; + }); + }, + ), + ], + ), + ], + ); + } +} diff --git a/packages/gyver_lamp_effects/gallery/lib/main.dart b/packages/gyver_lamp_effects/gallery/lib/main.dart new file mode 100644 index 0000000..7725b44 --- /dev/null +++ b/packages/gyver_lamp_effects/gallery/lib/main.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/app.dart'; + +void main() { + runApp(const App()); +} diff --git a/packages/gyver_lamp_effects/gallery/pubspec.yaml b/packages/gyver_lamp_effects/gallery/pubspec.yaml new file mode 100644 index 0000000..e0fed5f --- /dev/null +++ b/packages/gyver_lamp_effects/gallery/pubspec.yaml @@ -0,0 +1,21 @@ +name: gallery +description: Gallery project to showcase gyver_lamp_effects. +version: 1.0.0 +publish_to: 'none' + +environment: + sdk: '>=3.0.5 <4.0.0' + +dependencies: + flutter: + sdk: flutter + gyver_lamp_effects: + path: ../ + gyver_lamp_ui: + path: ../../gyver_lamp_ui + +dev_dependencies: + very_good_analysis: 4.0.0 + +flutter: + uses-material-design: true \ No newline at end of file diff --git a/packages/gyver_lamp_effects/gallery/web/favicon.png b/packages/gyver_lamp_effects/gallery/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/packages/gyver_lamp_effects/gallery/web/favicon.png differ diff --git a/packages/gyver_lamp_effects/gallery/web/icons/Icon-192.png b/packages/gyver_lamp_effects/gallery/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/packages/gyver_lamp_effects/gallery/web/icons/Icon-192.png differ diff --git a/packages/gyver_lamp_effects/gallery/web/icons/Icon-512.png b/packages/gyver_lamp_effects/gallery/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/packages/gyver_lamp_effects/gallery/web/icons/Icon-512.png differ diff --git a/packages/gyver_lamp_effects/gallery/web/icons/Icon-maskable-192.png b/packages/gyver_lamp_effects/gallery/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/packages/gyver_lamp_effects/gallery/web/icons/Icon-maskable-192.png differ diff --git a/packages/gyver_lamp_effects/gallery/web/icons/Icon-maskable-512.png b/packages/gyver_lamp_effects/gallery/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/packages/gyver_lamp_effects/gallery/web/icons/Icon-maskable-512.png differ diff --git a/packages/gyver_lamp_effects/gallery/web/index.html b/packages/gyver_lamp_effects/gallery/web/index.html new file mode 100644 index 0000000..332b4b2 --- /dev/null +++ b/packages/gyver_lamp_effects/gallery/web/index.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + gallery + + + + + + + + + + diff --git a/packages/gyver_lamp_effects/gallery/web/manifest.json b/packages/gyver_lamp_effects/gallery/web/manifest.json new file mode 100644 index 0000000..cf706d7 --- /dev/null +++ b/packages/gyver_lamp_effects/gallery/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "gallery", + "short_name": "gallery", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/packages/gyver_lamp_effects/lib/gyver_lamp_effects.dart b/packages/gyver_lamp_effects/lib/gyver_lamp_effects.dart new file mode 100644 index 0000000..de74d6d --- /dev/null +++ b/packages/gyver_lamp_effects/lib/gyver_lamp_effects.dart @@ -0,0 +1,2 @@ +export 'src/models/gyver_lamp_effect_type.dart'; +export 'src/widgets/gyver_lamp_effect.dart'; diff --git a/packages/gyver_lamp_effects/lib/src/extensions/extensions.dart b/packages/gyver_lamp_effects/lib/src/extensions/extensions.dart new file mode 100644 index 0000000..893846e --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/extensions/extensions.dart @@ -0,0 +1 @@ +export 'remap.dart'; diff --git a/packages/gyver_lamp_effects/lib/src/extensions/remap.dart b/packages/gyver_lamp_effects/lib/src/extensions/remap.dart new file mode 100644 index 0000000..cf3bd6e --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/extensions/remap.dart @@ -0,0 +1,7 @@ +/// Extension on [num] which remaps number to the new range. +extension NumRemap on num { + /// Remaps number from old range to the new one. + double remap(num fromLow, num fromHigh, num toLow, num toHigh) { + return (this - fromLow) * (toHigh - toLow) / (fromHigh - fromLow) + toLow; + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/clouds_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/clouds_frames_generator.dart new file mode 100644 index 0000000..696bf57 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/clouds_frames_generator.dart @@ -0,0 +1,36 @@ +import 'package:gyver_lamp_effects/src/generators/fast_led/fast_led.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +/// {@template clouds_frames_generator} +/// A frames generator which produces clouds effect frames. +/// {@endtemplate} +class CloudsFramesGenerator extends NoiseFramesGenerator { + /// {@macro clouds_frames_generator} + CloudsFramesGenerator({ + required super.dimension, + }) : super( + blur: 20, + looped: true, + palette: const [ + FastLedColors.blue, + FastLedColors.darkBlue, + FastLedColors.darkBlue, + FastLedColors.darkBlue, + // + FastLedColors.darkBlue, + FastLedColors.darkBlue, + FastLedColors.darkBlue, + FastLedColors.darkBlue, + // + FastLedColors.blue, + FastLedColors.darkBlue, + FastLedColors.skyBlue, + FastLedColors.skyBlue, + // + FastLedColors.lightBlue, + FastLedColors.white, + FastLedColors.lightBlue, + FastLedColors.skyBlue, + ], + ); +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/color_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/color_frames_generator.dart new file mode 100644 index 0000000..3525eae --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/color_frames_generator.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/extensions/extensions.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; + +/// {@template color_frames_generator} +/// A frames generator which produces solid color frames. +/// {@endtemplate} +final class ColorFramesGenerator extends FramesGenerator { + /// {@macro color_frames_generator} + ColorFramesGenerator({ + required super.dimension, + }) : super(blur: 0); + + int? _previousScale; + + Frame? _previousFrame; + + @override + Frame generate({ + required int scale, + required int speed, + }) { + assert( + scale >= 1 && scale <= 255, + 'the scale value must be in range [1, 255]', + ); + + if (scale == _previousScale && _previousFrame != null) { + return _previousFrame!; + } + + final color = HSLColor.fromAHSL( + 1, + (scale.remap(0, 255, 0, 360) * 2.5) % 360, + 1, + 0.7, + ).toColor(); + + final data = List.generate( + frameSize, + (_) => color, + growable: false, + ); + + final frame = Frame( + dimension: dimension, + data: data, + ); + + _previousScale = scale; + _previousFrame = frame; + + return frame; + } + + @override + void reset() { + _previousScale = null; + _previousFrame = null; + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/colors_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/colors_frames_generator.dart new file mode 100644 index 0000000..d79eb29 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/colors_frames_generator.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/extensions/extensions.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; + +/// {@template colors_frames_generator} +/// A frames generator which produces colored frames. +/// {@endtemplate} +final class ColorsFramesGenerator extends FramesGenerator { + /// {@macro colors_frames_generator} + ColorsFramesGenerator({ + required super.dimension, + }) : super(blur: 0); + + int _hue = 0; + + @override + Frame generate({ + required int scale, + required int speed, + }) { + assert( + scale >= 1 && scale <= 255, + 'the scale value must be in range [1, 255]', + ); + + _hue = (_hue + scale).toUnsigned(8); + + final color = HSLColor.fromAHSL( + 1, + _hue.remap(0, 255, 0, 360), + 1, + 0.7, + ).toColor(); + + final data = List.generate( + frameSize, + (_) => color, + growable: false, + ); + + return Frame( + dimension: dimension, + data: data, + ); + } + + @override + void reset() { + _hue = 0; + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/fast_led/fast_led.dart b/packages/gyver_lamp_effects/lib/src/generators/fast_led/fast_led.dart new file mode 100644 index 0000000..eabfbcd --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/fast_led/fast_led.dart @@ -0,0 +1,3 @@ +export 'fast_led_colors.dart'; +export 'fast_led_math.dart'; +export 'fast_led_noise.dart'; diff --git a/packages/gyver_lamp_effects/lib/src/generators/fast_led/fast_led_colors.dart b/packages/gyver_lamp_effects/lib/src/generators/fast_led/fast_led_colors.dart new file mode 100644 index 0000000..a224dee --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/fast_led/fast_led_colors.dart @@ -0,0 +1,529 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/generators/fast_led/fast_led.dart'; + +/// FastLED library predefined colors. +/// +/// Those colors are copied from the list available at: +/// https://github.com/FastLED/FastLED/wiki/Pixel-reference#predefined-colors-list +/// +/// Note that colors defined in the FastLED library has no alpha value and thus +/// can't be used directly to create Flutter [Color]. +abstract class FastLedColors { + /// Alice Blue color. + static const aliceBlue = 0xF0F8FF; + + /// Amethyst color. + static const amethyst = 0x9966CC; + + /// Antique White color. + static const antiqueWhite = 0xFAEBD7; + + /// Aqua color. + static const aqua = 0x00FFFF; + + /// Aquamarine color. + static const aquamarine = 0x7FFFD4; + + /// Azure color. + static const azure = 0xF0FFFF; + + /// Beige color. + static const beige = 0xF5F5DC; + + /// Bisque color. + static const bisque = 0xFFE4C4; + + /// Black color. + static const black = 0x000000; + + /// Blanched Almond color. + static const blanchedAlmond = 0xFFEBCD; + + /// Blue color. + static const blue = 0x0000FF; + + /// Blue Violet color. + static const blueViolet = 0x8A2BE2; + + /// Brown color. + static const brown = 0xA52A2A; + + /// Burly Wood color. + static const burlyWood = 0xDEB887; + + /// Cadet Blue color. + static const cadetBlue = 0x5F9EA0; + + /// Chartreuse color. + static const chartreuse = 0x7FFF00; + + /// Chocolate color. + static const chocolate = 0xD2691E; + + /// Coral color. + static const coral = 0xFF7F50; + + /// Cornflower Blue color. + static const cornflowerBlue = 0x6495ED; + + /// Corn Silk color. + static const cornSilk = 0xFFF8DC; + + /// Crimson color. + static const crimson = 0xDC143C; + + /// Cyan color. + static const cyan = 0x00FFFF; + + /// Dark Blue color. + static const darkBlue = 0x00008B; + + /// Dark Cyan color. + static const darkCyan = 0x008B8B; + + /// Dark Goldenrod color. + static const darkGoldenrod = 0xB8860B; + + /// Dark Grey color. + static const darkGrey = 0xA9A9A9; + + /// Dark Green color. + static const darkGreen = 0x006400; + + /// Dark Khaki color. + static const darkKhaki = 0xBDB76B; + + /// Dark Magenta color. + static const darkMagenta = 0x8B008B; + + /// Dark Olive Green color. + static const darkOliveGreen = 0x556B2F; + + /// Dark Orange color. + static const darkOrange = 0xFF8C00; + + /// Dark Orchid color. + static const darkOrchid = 0x9932CC; + + /// Dark Red color. + static const darkRed = 0x8B0000; + + /// Dark Salmon color. + static const darkSalmon = 0xE9967A; + + /// Dark Sea Green color. + static const darkSeaGreen = 0x8FBC8F; + + /// Dark Slate Blue color. + static const darkSlateBlue = 0x483D8B; + + /// Dark Slate Grey color. + static const darkSlateGrey = 0x2F4F4F; + + /// Dark Turquoise color. + static const darkTurquoise = 0x00CED1; + + /// Dark Violet color. + static const darkViolet = 0x9400D3; + + /// Deep Pink color. + static const deepPink = 0xFF1493; + + /// Deep Sky Blue color. + static const deepSkyBlue = 0x00BFFF; + + /// Deem Grey color. + static const dimGrey = 0x696969; + + /// Dodger Blue color. + static const dodgerBlue = 0x1E90FF; + + /// Fire Black color. + static const fireBrick = 0xB22222; + + /// Floral White color. + static const floralWhite = 0xFFFAF0; + + /// Forest Green color. + static const forestGreen = 0x228B22; + + /// Fuchsia color. + static const fuchsia = 0xFF00FF; + + /// Gainsboro color. + static const gainsboro = 0xDCDCDC; + + /// Ghost White color. + static const ghostWhite = 0xF8F8FF; + + /// Gold color. + static const gold = 0xFFD700; + + /// Goldenrod color. + static const goldenrod = 0xDAA520; + + /// Grey color. + static const grey = 0x808080; + + /// Green color. + static const green = 0x008000; + + /// Green Yellow color. + static const greenYellow = 0xADFF2F; + + /// Honeydew color. + static const honeydew = 0xF0FFF0; + + /// Hot Pink color. + static const hotPink = 0xFF69B4; + + /// Indian Red color. + static const indianRed = 0xCD5C5C; + + /// Indigo color. + static const indigo = 0x4B0082; + + /// Ivory color. + static const ivory = 0xFFFFF0; + + /// Khaki color. + static const khaki = 0xF0E68C; + + /// Lavender color. + static const lavender = 0xE6E6FA; + + /// Lavender Blush color. + static const lavenderBlush = 0xFFF0F5; + + /// Lawn Green color. + static const lawnGreen = 0x7CFC00; + + /// Lemon Chiffon color. + static const lemonChiffon = 0xFFFACD; + + /// Light Blue color. + static const lightBlue = 0xADD8E6; + + /// Light Coral color. + static const lightCoral = 0xF08080; + + /// Light Cyan color. + static const lightCyan = 0xE0FFFF; + + /// Light Goldenrod Yellow color. + static const lightGoldenrodYellow = 0xFAFAD2; + + /// Light Green color. + static const lightGreen = 0x90EE90; + + /// Light Grey color. + static const lightGrey = 0xD3D3D3; + + /// Light Pink color. + static const lightPink = 0xFFB6C1; + + /// Light Salmon color. + static const lightSalmon = 0xFFA07A; + + /// Light Sea Green color. + static const lightSeaGreen = 0x20B2AA; + + /// Light Sky Blue color. + static const lightSkyBlue = 0x87CEFA; + + /// Light Slate Grey color. + static const lightSlateGrey = 0x778899; + + /// Light Steel Blue color. + static const lightSteelBlue = 0xB0C4DE; + + /// Light Yellow color. + static const lightYellow = 0xFFFFE0; + + /// Lime color. + static const lime = 0x00FF00; + + /// Lime Green color. + static const limeGreen = 0x32CD32; + + /// Linen color. + static const linen = 0xFAF0E6; + + /// Magenta color. + static const magenta = 0xFF00FF; + + /// Maroon color. + static const maroon = 0x800000; + + /// Medium Aquamarine color. + static const mediumAquamarine = 0x66CDAA; + + /// Medium Blue color. + static const mediumBlue = 0x0000CD; + + /// Medium Orchid color. + static const mediumOrchid = 0xBA55D3; + + /// Medium Purple color. + static const mediumPurple = 0x9370DB; + + /// Medium Sea Green color. + static const mediumSeaGreen = 0x3CB371; + + /// Medium Slate Blue color. + static const mediumSlateBlue = 0x7B68EE; + + /// Medium Spring Green color. + static const mediumSpringGreen = 0x00FA9A; + + /// Medium Turquoise color. + static const mediumTurquoise = 0x48D1CC; + + /// Medium Violet Red color. + static const mediumVioletRed = 0xC71585; + + /// Midnight Blue color. + static const midnightBlue = 0x191970; + + /// Mint Cream color. + static const mintCream = 0xF5FFFA; + + /// Misty Rose color. + static const mistyRose = 0xFFE4E1; + + /// Moccasin color. + static const moccasin = 0xFFE4B5; + + /// Navajo White color. + static const navajoWhite = 0xFFDEAD; + + /// Navy color. + static const navy = 0x000080; + + /// Old Lace color. + static const oldLace = 0xFDF5E6; + + /// Olive color. + static const olive = 0x808000; + + /// Olive Drab color. + static const oliveDrab = 0x6B8E23; + + /// Orange color. + static const orange = 0xFFA500; + + /// Orange Red color. + static const orangeRed = 0xFF4500; + + /// Orchid color. + static const orchid = 0xDA70D6; + + /// Pale Goldenrod color. + static const paleGoldenrod = 0xEEE8AA; + + /// Pale Green color. + static const paleGreen = 0x98FB98; + + /// Pale Turquoise color. + static const paleTurquoise = 0xAFEEEE; + + /// Pale Violet Red color. + static const paleVioletRed = 0xDB7093; + + /// Papaya Whip color. + static const papayaWhip = 0xFFEFD5; + + /// Peach Puff color. + static const peachPuff = 0xFFDAB9; + + /// Peru color. + static const peru = 0xCD853F; + + /// Pink color. + static const pink = 0xFFC0CB; + + /// Plaid color. + static const plaid = 0xCC5533; + + /// Plum color. + static const plum = 0xDDA0DD; + + /// Powder Blue color. + static const powderBlue = 0xB0E0E6; + + /// Purple color. + static const purple = 0x800080; + + /// Red color. + static const red = 0xFF0000; + + /// Rosy Brown color. + static const rosyBrown = 0xBC8F8F; + + /// Royal Blue color. + static const royalBlue = 0x4169E1; + + /// Saddle Brown color. + static const saddleBrown = 0x8B4513; + + /// Salmon color. + static const salmon = 0xFA8072; + + /// Sandy Brown color. + static const sandyBrown = 0xF4A460; + + /// Sea Green color. + static const seaGreen = 0x2E8B57; + + /// Seashell color. + static const seashell = 0xFFF5EE; + + /// Sienna color. + static const sienna = 0xA0522D; + + /// Silver color. + static const silver = 0xC0C0C0; + + /// Sky Blue color. + static const skyBlue = 0x87CEEB; + + /// Slate Blue color. + static const slateBlue = 0x6A5ACD; + + /// Slate Grey color. + static const slateGrey = 0x708090; + + /// Snow color. + static const snow = 0xFFFAFA; + + /// Spring Green color. + static const springGreen = 0x00FF7F; + + /// Steel Blue color. + static const steelBlue = 0x4682B4; + + /// Tan color. + static const tan = 0xD2B48C; + + /// Teal color. + static const teal = 0x008080; + + /// Thistle color. + static const thistle = 0xD8BFD8; + + /// Tomato color. + static const tomato = 0xFF6347; + + /// Turquoise color. + static const turquoise = 0x40E0D0; + + /// Violet color. + static const violet = 0xEE82EE; + + /// Wheat color. + static const wheat = 0xF5DEB3; + + /// White color. + static const white = 0xFFFFFF; + + /// White Smoke color. + static const whiteSmoke = 0xF5F5F5; + + /// Yellow color. + static const yellow = 0xFFFF00; + + /// Yellow Green color. + static const yellowGreen = 0x9ACD32; + + /// Fairy Light color. + static const fairyLight = 0xFFE42D; + + /// Picks color from a specified palette. + /// + /// Note that palette have to be 16 entries long. + /// Even though the palette has only 16 explicitly defined entries, we can + /// use an [index] from [0..255] to get 256 colors. The 16 explicit palette + /// entries will be spread evenly across the [0..255] range, and the + /// intermediate values will be RGB-interpolated between adjacent explicit + /// entries. + /// The [brightness] value have to be in range [0..255]. + /// + /// The output color has an alpha value and thus can be used to create + /// Flutter [Color] without any additional manipulations. + /// + /// This function is ported from a FastLED C++ source code: + /// https://github.com/FastLED/FastLED/blob/master/src/colorutils.cpp + static int colorFromPalette({ + required List palette, + required int index, + required int brightness, + }) { + assert(palette.length == 16, 'palette have to contain 16 entries'); + + final ui = index.toUnsigned(8); + var bri = brightness.toUnsigned(8); + final hi4 = FastLedMath.lsrX4(ui); + final lo4 = ui & 0x0F; + + final color = palette[hi4]; + + var red1 = (color >> 16) & 0xFF; + var green1 = (color >> 8) & 0xFF; + var blue1 = color & 0xFF; + + if (lo4 > 0) { + final int nextColor; + + if (hi4 == 15) { + nextColor = palette[0]; + } else { + nextColor = palette[hi4 + 1]; + } + + final f2 = lo4 << 4; + final f1 = 255 - f2; + + var red2 = (nextColor >> 16) & 0xFF; + red1 = FastLedMath.scale8(red1, f1); + red2 = FastLedMath.scale8(red2, f2); + red1 += red2; + red1 = red1.toUnsigned(8); + + var green2 = (nextColor >> 8) & 0xFF; + green1 = FastLedMath.scale8(green1, f1); + green2 = FastLedMath.scale8(green2, f2); + green1 += green2; + green1 = green1.toUnsigned(8); + + var blue2 = nextColor & 0xFF; + blue1 = FastLedMath.scale8(blue1, f1); + blue2 = FastLedMath.scale8(blue2, f2); + blue1 += blue2; + blue1 = blue1.toUnsigned(8); + } + + if (bri != 255) { + if (bri > 0) { + bri++; + + if (red1 > 0) { + red1 = FastLedMath.scale8(red1, bri); + } + + if (green1 > 0) { + green1 = FastLedMath.scale8(green1, bri); + } + + if (blue1 > 0) { + blue1 = FastLedMath.scale8(blue1, bri); + } + } else { + red1 = 0; + green1 = 0; + blue1 = 0; + } + } + + return (0xFF << 24) | (red1 << 16) | (green1 << 8) | blue1; + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/fast_led/fast_led_math.dart b/packages/gyver_lamp_effects/lib/src/generators/fast_led/fast_led_math.dart new file mode 100644 index 0000000..eca6e29 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/fast_led/fast_led_math.dart @@ -0,0 +1,123 @@ +// ignore_for_file: non_constant_identifier_names + +/// FastLED 8-bit math functions. +/// +/// Those functions are ported from a FastLED C++ source code: +/// https://github.com/FastLED/FastLED/tree/master/src/lib8tion +abstract class FastLedMath { + /// Add `a` to `b`, saturating at `0xFF`. + /// Also known as eight-bit saturating addition. + static int qadd8(int a, int b) { + return (a.toUnsigned(8) + b.toUnsigned(8)).clamp(0, 255); + } + + /// Subtracting `b` from `a`, saturating at `0x00`. + /// Also known as eight-bit saturating subtraction. + static int qsub8(int a, int b) { + return (a.toUnsigned(8) - b.toUnsigned(8)).clamp(0, 255); + } + + /// Calculate an average of 7-bit signed `a` and `b`. + /// + /// If the first argument is even, result is rounded down. + /// If the first argument is odd, result is rounded up. + static int avg7(int a, int b) { + final sa = a.toSigned(8); + final sb = b.toSigned(8); + + final result = ((sa + sb) >> 1) + (sa & 0x1); + + return result.toSigned(8); + } + + /// Scales `i` by a `scale`, which is treated as the numerator of a fraction + /// whose denominator is `256`. + /// + /// In other words, it computes `i * (scale / 256)`. + static int scale8(int i, int scale) { + final ui = i.toUnsigned(8); + final us = scale.toUnsigned(8); + + final result = (ui * (1 + us)) >> 8; + + return result.toUnsigned(8); + } + + /// Adjusts a scaling value for dimming. + static int dim8_raw(int x) { + return scale8(x, x); + } + + /// Linearly interpolates between two signed 8-bit integers `a` and `b` with + /// a fractional factor `frac`. + static int lerp7by8(int a, int b, int frac) { + final int result; + + if (b > a) { + final delta = (b - a).toUnsigned(8); + final scaled = scale8(delta, frac); + result = a + scaled; + } else { + final delta = (a - b).toUnsigned(8); + final scaled = scale8(delta, frac); + result = a - scaled; + } + + return result.toSigned(8); + } + + /// Applies a quadratic ease-in-out function to an 8-bit `i`. + static int ease8InOutQuad(int i) { + var j = i.toUnsigned(8); + + if ((j & 0x80) != 0) { + j = 255 - j; + } + + final jj = scale8(j, j); + var jj2 = jj << 1; + + if ((i.toUnsigned(8) & 0x80) != 0) { + jj2 = 255 - jj2; + } + + return jj2.toUnsigned(8); + } + + /// Divides number by 16. + static int lsrX4(int x) { + return x.toUnsigned(8) >> 4; + } + + /// Generates a 3D gradient noise value based on a given hash `h` and + /// coordinates `x`, `y`, `z`. + static int grad8(int h, int x, int y, int z) { + final hash = h.toUnsigned(8) & 0xF; + final sx = x.toSigned(8); + final sy = y.toSigned(8); + final sz = z.toSigned(8); + + var u = selectBasedOnHashBit(hash, 3, sy, sx); + var v = hash < 4 ? sy : (hash == 12 || hash == 14 ? sx : sz); + + if ((hash & 1) != 0) { + u = -u; + } + + if ((hash & 2) != 0) { + v = -v; + } + + return avg7(u, v); + } + + /// Selects a value based on a specific `bit` of a hash value `h`. + /// + /// This function selects one of two values, `a` and `b`, based on the value + /// of a specific `bit` within a hash value `h` (an unsigned 8-bit integer). + /// If the selected bit is 0, `b` is returned; otherwise `a` is returned. + static int selectBasedOnHashBit(int hash, int bit, int a, int b) { + final result = hash.toUnsigned(8) & (1 << bit.toUnsigned(8)); + return result != 0 ? a.toSigned(8) : b.toSigned(8); + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/fast_led/fast_led_noise.dart b/packages/gyver_lamp_effects/lib/src/generators/fast_led/fast_led_noise.dart new file mode 100644 index 0000000..bfce9a4 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/fast_led/fast_led_noise.dart @@ -0,0 +1,97 @@ +// ignore_for_file: non_constant_identifier_names + +import 'package:gyver_lamp_effects/src/generators/fast_led/fast_led.dart'; + +/// FastLED 8-bit noise functions. +/// +/// Those functions are ported from a FastLED C++ source code: +/// https://github.com/FastLED/FastLED/blob/master/src/noise.cpp +abstract class FastLedNoise { + /// Generates 8-bit Perlin noise at a specified point in 3D space. + static int inoise8(int x, int y, int z) { + final ux = x.toUnsigned(16); + final uy = y.toUnsigned(16); + final uz = z.toUnsigned(16); + + var n = _inoise8_raw(ux, uy, uz); // -64..+64 + n += 64; // 0..128 + + return FastLedMath.qadd8(n, n); // 0..255 + } + + static int _inoise8_raw(int x, int y, int z) { + // Find the unit cube containing the point + final X = x.toUnsigned(16) >> 8; + final Y = y.toUnsigned(16) >> 8; + final Z = z.toUnsigned(16) >> 8; + + // Hash cube corner coordinates + final A = (_p[X] + Y).toUnsigned(8); + final AA = (_p[A] + Z).toUnsigned(8); + final AB = (_p[A + 1] + Z).toUnsigned(8); + final B = (_p[X + 1] + Y).toUnsigned(8); + final BA = (_p[B] + Z).toUnsigned(8); + final BB = (_p[B + 1] + Z).toUnsigned(8); + + // Get the relative position of the point in the cube + var u = x.toUnsigned(8); + var v = y.toUnsigned(8); + var w = z.toUnsigned(8); + + // Get a signed version of the above for the grad function + final xx = ((x.toUnsigned(8) >> 1) & 0x7F).toSigned(8); + final yy = ((y.toUnsigned(8) >> 1) & 0x7F).toSigned(8); + final zz = ((z.toUnsigned(8) >> 1) & 0x7F).toSigned(8); + const N = 0x80; + + u = FastLedMath.ease8InOutQuad(u); + v = FastLedMath.ease8InOutQuad(v); + w = FastLedMath.ease8InOutQuad(w); + + final X1 = FastLedMath.lerp7by8( + FastLedMath.grad8(_p[AA], xx, yy, zz), + FastLedMath.grad8(_p[BA], xx - N, yy, zz), + u, + ); + final X2 = FastLedMath.lerp7by8( + FastLedMath.grad8(_p[AB], xx, yy - N, zz), + FastLedMath.grad8(_p[BB], xx - N, yy - N, zz), + u, + ); + final X3 = FastLedMath.lerp7by8( + FastLedMath.grad8(_p[AA + 1], xx, yy, zz - N), + FastLedMath.grad8(_p[BA + 1], xx - N, yy, zz - N), + u, + ); + final X4 = FastLedMath.lerp7by8( + FastLedMath.grad8(_p[AB + 1], xx, yy - N, zz - N), + FastLedMath.grad8(_p[BB + 1], xx - N, yy - N, zz - N), + u, + ); + + final Y1 = FastLedMath.lerp7by8(X1, X2, v); + final Y2 = FastLedMath.lerp7by8(X3, X4, v); + + return FastLedMath.lerp7by8(Y1, Y2, w); + } + + static const _p = [ + 151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, // + 140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148, + 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, + 57, 177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, + 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122, + 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, + 65, 25, 63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169, + 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64, + 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, + 207, 206, 59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170, 213, + 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9, + 129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, + 218, 246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241, + 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157, + 184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, + 222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180, 151 + // ignore: require_trailing_commas + ]; +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/fire_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/fire_frames_generator.dart new file mode 100644 index 0000000..c78a722 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/fire_frames_generator.dart @@ -0,0 +1,243 @@ +import 'dart:math' as math show Random, max; + +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/extensions/extensions.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; + +const _kMaxX = 16; +const _kMaxY = 8; + +/// {@template fire_frames_generator} +/// A frames generator which produces fire effect frames. +/// {@endtemplate} +final class FireFramesGenerator extends FramesGenerator { + /// {@macro fire_frames_generator} + FireFramesGenerator({ + required super.dimension, + math.Random? random, + }) : assert( + dimension >= 16, + 'dimension must be >= 16', + ), + _random = random ?? math.Random(), + super(blur: 5) { + _init(); + } + + final math.Random _random; + + late int _counter; + + late List _line; + + late List _previousData; + + Frame? _previousFrame; + + static const _valueMask = [ + [32, 0, 0, 0, 0, 0, 0, 32, 32, 0, 0, 0, 0, 0, 0, 32], + [64, 0, 0, 0, 0, 0, 0, 64, 64, 0, 0, 0, 0, 0, 0, 64], + [96, 32, 0, 0, 0, 0, 32, 96, 96, 32, 0, 0, 0, 0, 32, 96], + [128, 64, 32, 0, 0, 32, 64, 128, 128, 64, 32, 0, 0, 32, 64, 128], + [160, 96, 64, 32, 32, 64, 96, 160, 160, 96, 64, 32, 32, 64, 96, 160], + [192, 128, 96, 64, 64, 96, 128, 192, 192, 128, 96, 64, 64, 96, 128, 192], + [ + 255, + 160, + 128, + 96, + 96, + 128, + 160, + 255, + 255, + 160, + 128, + 96, + 96, + 128, + 160, + 255, + ], + [ + 255, + 192, + 160, + 128, + 128, + 160, + 192, + 255, + 255, + 192, + 160, + 128, + 128, + 160, + 192, + 255, + ], + ]; + + static const _hueMask = [ + [1, 11, 19, 25, 25, 22, 11, 1, 1, 11, 19, 25, 25, 22, 11, 1], + [1, 8, 13, 19, 25, 19, 8, 1, 1, 8, 13, 19, 25, 19, 8, 1], + [1, 8, 13, 16, 19, 16, 8, 1, 1, 8, 13, 16, 19, 16, 8, 1], + [1, 5, 11, 13, 13, 13, 5, 1, 1, 5, 11, 13, 13, 13, 5, 1], + [1, 5, 11, 11, 11, 11, 5, 1, 1, 5, 11, 11, 11, 11, 5, 1], + [0, 1, 5, 8, 8, 5, 1, 0, 0, 1, 5, 8, 8, 5, 1, 0], + [0, 0, 1, 5, 5, 1, 0, 0, 0, 0, 1, 5, 5, 1, 0, 0], + [0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0], + ]; + + void _init() { + _counter = 0; + + _line = List.generate( + dimension, + (_) => 0, + growable: false, + ); + + _previousData = List.generate( + frameSize, + (_) => 0, + growable: false, + ); + + _generateLine(_line); + } + + void _generateLine(List line) { + for (var x = 0; x < dimension; x++) { + line[x] = _random.nextInt(255 - 64) + 64; + } + } + + void _shiftUp(List data) { + for (var y = dimension - 1 - _kMaxY; y < dimension - 1; y++) { + final offset = y * dimension; + + for (var x = 0; x < dimension; x++) { + data[offset + x] = data[(y + 1) * dimension + x]; + } + } + + final offset = dimension * (dimension - 1); + + for (var x = 0; x < dimension; x++) { + data[offset + x] = _line[x]; + } + } + + @override + Frame generate({ + required int scale, + required int speed, + }) { + assert( + scale >= 1 && scale <= 255, + 'the scale value must be in range [1, 255]', + ); + + if (_counter >= 100) { + _shiftUp(_previousData); + _generateLine(_line); + _counter = 0; + } + + final frameData = List.generate( + frameSize, + (_) => Colors.black, + growable: false, + ); + + for (var y = 0; y < dimension - 1; y++) { + for (var x = 0; x < dimension; x++) { + // Index of the current pixel. + final currentIndex = y * dimension + x; + // Index of the previous pixel (bottom one). + final prevIndex = (y + 1) * dimension + x; + + switch (dimension - 1 - y) { + case > _kMaxY: + final color = _previousFrame?.data[prevIndex]; + frameData[currentIndex] = color ?? Colors.black; + + case == _kMaxY: + final color = _previousFrame?.data[prevIndex] ?? Colors.black; + final random = _random.nextInt(20); + + if (random == 0 && color != Colors.black) { + frameData[currentIndex] = color; + } else { + frameData[currentIndex] = Colors.black; + } + + default: + final maskX = x % _kMaxX; + final maskY = dimension - 2 - y; + + final hueMask = _hueMask[maskY][maskX]; + final valueMask = _valueMask[maskY][maskX]; + + final hue = (scale * 2.5 + hueMask).toInt(); + + final d1 = _previousData[currentIndex]; + final d2 = _previousData[(y + 1) * dimension + x]; + final value = + (((100 - _counter) * d1 + _counter * d2) ~/ 100) - valueMask; + + final color = HSVColor.fromAHSV( + 1, + hue.toUnsigned(8).remap(0, 255, 0, 360), + 1, + math.max(0, value).toUnsigned(8).remap(0, 255, 0, 1), + ); + + frameData[currentIndex] = color.toColor(); + } + } + } + + final offset = dimension * (dimension - 1); + + for (var x = 0; x < dimension; x++) { + final maskX = x % _kMaxX; + final hueMask = _hueMask[0][maskX]; + + final hue = (scale * 2.5 + hueMask).toInt(); + + final d = _previousData[offset + x]; + final l = _line[x]; + final value = ((100 - _counter) * d + _counter * l) ~/ 100; + + final color = HSVColor.fromAHSV( + 1, + hue.toUnsigned(8).remap(0, 255, 0, 360), + 1, + value.toUnsigned(8).remap(0, 255, 0, 1), + ); + + frameData[offset + x] = color.toColor(); + } + + _counter += 30; + + final frame = Frame( + dimension: dimension, + data: frameData, + ); + + _previousFrame = frame; + + return frame; + } + + @override + void reset() { + _previousFrame = null; + _init(); + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/fireflies_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/fireflies_frames_generator.dart new file mode 100644 index 0000000..d7ac5a1 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/fireflies_frames_generator.dart @@ -0,0 +1,144 @@ +import 'dart:math' as math show Random; + +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; + +const _kMaxFirefliesCount = 100; + +/// {@template fireflies_frames_generator} +/// A frames generator which produces fireflies effect frames. +/// {@endtemplate} +final class FirefliesFramesGenerator extends FramesGenerator { + /// {@macro fireflies_frames_generator} + FirefliesFramesGenerator({ + required super.dimension, + math.Random? random, + }) : _random = random ?? math.Random(), + super(blur: 3) { + _init(); + } + + final math.Random _random; + + late int _counter; + + late List<({int x, int y})> _position; + late List<({int h, int v})> _speed; + late List _color; + + void _init() { + _counter = 0; + + _position = List.generate( + _kMaxFirefliesCount, + (_) => (x: 0, y: 0), + ); + + _speed = List.generate( + _kMaxFirefliesCount, + (_) => (h: 0, v: 0), + ); + + _color = List.generate( + _kMaxFirefliesCount, + (_) => Colors.black, + ); + + for (var i = 0; i < _kMaxFirefliesCount; i++) { + _position[i] = ( + x: _random.nextInt(dimension * 10), + y: _random.nextInt(dimension * 10), + ); + + _speed[i] = ( + h: _random.nextInt(20) - 10, + v: _random.nextInt(20) - 10, + ); + + _color[i] = HSLColor.fromAHSL( + 1, + _random.nextInt(360).toDouble(), + 1, + 0.6, + ).toColor(); + } + } + + @override + Frame generate({ + required int scale, + required int speed, + }) { + assert( + scale >= 1 && scale <= 255, + 'the scale value must be in range [1, 255]', + ); + + _counter = (_counter + 1) % 20; + + final data = List.generate( + frameSize, + (_) => Colors.black, + growable: false, + ); + + final amount = scale.clamp(1, _kMaxFirefliesCount); + + for (var i = 0; i < amount; i++) { + if (_counter == 0) { + final speed = _speed[i]; + + final dh = _random.nextInt(8) - 4; + final dv = _random.nextInt(8) - 4; + + _speed[i] = ( + h: (speed.h + dh).clamp(-20, 20), + v: (speed.v + dv).clamp(-20, 20), + ); + } + + var position = _position[i]; + var speed = _speed[i]; + + var newX = position.x + speed.h; + var newY = position.y + speed.v; + + if (newX < 0) { + newX = (dimension - 1) * 10; + } else if (newX >= dimension * 10) { + newX = 0; + } + + if (newY < 0) { + newY = 0; + speed = (h: speed.h, v: -speed.v); + } else if (newY >= dimension * 10) { + newY = (dimension - 1) * 10; + speed = (h: speed.h, v: -speed.v); + } + + position = (x: newX, y: newY); + + _position[i] = position; + _speed[i] = speed; + + final x = position.x ~/ 10; + final y = position.y ~/ 10; + + data[y * dimension + x] = _color[i]; + } + + final frame = Frame( + dimension: dimension, + data: data, + ); + + return frame; + } + + @override + void reset() { + _init(); + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/forest_frames_painter.dart b/packages/gyver_lamp_effects/lib/src/generators/forest_frames_painter.dart new file mode 100644 index 0000000..73fe3a9 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/forest_frames_painter.dart @@ -0,0 +1,36 @@ +import 'package:gyver_lamp_effects/src/generators/fast_led/fast_led.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +/// {@template forest_frames_generator} +/// A frames generator which produces forest effect frames. +/// {@endtemplate} +class ForestFramesGenerator extends NoiseFramesGenerator { + /// {@macro forest_frames_generator} + ForestFramesGenerator({ + required super.dimension, + }) : super( + blur: 20, + looped: true, + palette: const [ + FastLedColors.darkGreen, + FastLedColors.darkGreen, + FastLedColors.darkOliveGreen, + FastLedColors.darkGreen, + // + FastLedColors.green, + FastLedColors.forestGreen, + FastLedColors.oliveDrab, + FastLedColors.green, + // + FastLedColors.seaGreen, + FastLedColors.mediumAquamarine, + FastLedColors.limeGreen, + FastLedColors.yellowGreen, + // + FastLedColors.lightGreen, + FastLedColors.lawnGreen, + FastLedColors.mediumAquamarine, + FastLedColors.forestGreen, + ], + ); +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/frames_generator.dart new file mode 100644 index 0000000..0c2a6c4 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/frames_generator.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; + +/// {@template frames_generator} +/// The base class for a generator that produces frames. +/// {@endtemplate} +abstract class FramesGenerator { + /// {@macro frames_generator} + const FramesGenerator({ + required this.dimension, + required this.blur, + }); + + /// The length of the frame's side. + final int dimension; + + /// The blur value which need to be applied onto a frame. + final double blur; + + /// The size of the produced frame. + @protected + int get frameSize => dimension * dimension; + + /// Produces the next frame considering the [dimension], [speed], and [scale]. + Frame generate({ + required int speed, + required int scale, + }); + + /// Resets generator to the initial state. + void reset(); +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/generators.dart b/packages/gyver_lamp_effects/lib/src/generators/generators.dart new file mode 100644 index 0000000..636001d --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/generators.dart @@ -0,0 +1,20 @@ +export 'clouds_frames_generator.dart'; +export 'color_frames_generator.dart'; +export 'colors_frames_generator.dart'; +export 'fire_frames_generator.dart'; +export 'fireflies_frames_generator.dart'; +export 'forest_frames_painter.dart'; +export 'frames_generator.dart'; +export 'horizontal_rainbow_frames_generator.dart'; +export 'lava_frames_generator.dart'; +export 'madness_frames_generator.dart'; +export 'matrix_frames_generator.dart'; +export 'noise_frames_generator.dart'; +export 'ocean_frames_generator.dart'; +export 'plasma_frames_generator.dart'; +export 'rainbow_frames_generator.dart'; +export 'rainbow_stripes_frames_generator.dart'; +export 'snow_frames_generator.dart'; +export 'sparkles_frames_generator.dart'; +export 'vertical_rainbow_frames_generator.dart'; +export 'zebra_frames_painter.dart'; diff --git a/packages/gyver_lamp_effects/lib/src/generators/horizontal_rainbow_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/horizontal_rainbow_frames_generator.dart new file mode 100644 index 0000000..d012ba1 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/horizontal_rainbow_frames_generator.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/extensions/extensions.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; + +/// {@template horizontal_rainbow_frames_generator} +/// A frames generator which produces horizontal rainbow effect frames. +/// {@endtemplate} +final class HorizontalRainbowFramesGenerator extends FramesGenerator { + /// {@macro horizontal_rainbow_frames_generator} + HorizontalRainbowFramesGenerator({ + required super.dimension, + }) : super(blur: 10); + + int _hue = 0; + + @override + Frame generate({ + required int scale, + required int speed, + }) { + assert( + scale >= 1 && scale <= 255, + 'the scale value must be in range [1, 255]', + ); + + _hue = (_hue + 2).toUnsigned(8); + + final data = List.generate( + frameSize, + (_) => Colors.black, + growable: false, + ); + + for (var x = 0; x < dimension; x++) { + final hue = (_hue + x * scale).toUnsigned(8).remap(0, 255, 0, 360); + + final color = HSLColor.fromAHSL(1, hue, 1, 0.7).toColor(); + + for (var y = 0; y < dimension; y++) { + data[y * dimension + x] = color; + } + } + + return Frame( + dimension: dimension, + data: data, + ); + } + + @override + void reset() { + _hue = 0; + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/lava_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/lava_frames_generator.dart new file mode 100644 index 0000000..73ce399 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/lava_frames_generator.dart @@ -0,0 +1,35 @@ +import 'package:gyver_lamp_effects/src/generators/fast_led/fast_led.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +/// {@template lava_frames_generator} +/// A frames generator which produces lava effect frames. +/// {@endtemplate} +class LavaFramesGenerator extends NoiseFramesGenerator { + /// {@macro lava_frames_generator} + LavaFramesGenerator({ + required super.dimension, + }) : super( + blur: 20, + palette: const [ + FastLedColors.black, + FastLedColors.maroon, + FastLedColors.black, + FastLedColors.maroon, + // + FastLedColors.darkRed, + FastLedColors.darkRed, + FastLedColors.maroon, + FastLedColors.darkRed, + // + FastLedColors.darkRed, + FastLedColors.darkRed, + FastLedColors.red, + FastLedColors.orange, + // + FastLedColors.white, + FastLedColors.orange, + FastLedColors.red, + FastLedColors.darkRed, + ], + ); +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/madness_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/madness_frames_generator.dart new file mode 100644 index 0000000..c1ac4aa --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/madness_frames_generator.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/extensions/extensions.dart'; +import 'package:gyver_lamp_effects/src/generators/fast_led/fast_led.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; + +/// {@template madness_frames_generator} +/// A frames generator which produces madness effect frames. +/// {@endtemplate} +class MadnessFramesGenerator extends FramesGenerator { + /// {@macro madness_frames_generator} + MadnessFramesGenerator({ + required super.dimension, + }) : super(blur: 20) { + _init(); + } + + int _z = 0; + + late List _noise; + + void _init() { + _z = 0; + _noise = List.generate(frameSize, (_) => 0); + } + + @override + Frame generate({ + required int scale, + required int speed, + }) { + assert( + scale >= 1 && scale <= 255, + 'the scale value must be in range [1, 255]', + ); + + for (var i = 0; i < dimension; i++) { + final iOffset = scale * i; + + for (var j = 0; j < dimension; j++) { + final jOffset = scale * j; + + _noise[i * dimension + j] = FastLedNoise.inoise8( + iOffset, + jOffset, + _z, + ); + } + } + + _z += speed; + _z = _z.toUnsigned(16); + + final data = List.generate(frameSize, (_) => Colors.black); + + for (var i = 0; i < dimension; i++) { + for (var j = 0; j < dimension; j++) { + final n1 = _noise[j * dimension + i]; + final n2 = _noise[i * dimension + j]; + + data[j * dimension + i] = HSLColor.fromAHSL( + 1, + n1.remap(0, 255, 0, 360), + 1, + n2.remap(0, 255, 0.3, 0.8), + ).toColor(); + } + } + + return Frame( + dimension: dimension, + data: data, + ); + } + + @override + void reset() { + _init(); + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/matrix_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/matrix_frames_generator.dart new file mode 100644 index 0000000..dc10d12 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/matrix_frames_generator.dart @@ -0,0 +1,90 @@ +import 'dart:math' as math show Random; + +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; + +/// {@template matrix_frames_generator} +/// A frames generator which produces matrix effect frames. +/// {@endtemplate} +final class MatrixFramesGenerator extends FramesGenerator { + /// {@macro matrix_frames_generator} + MatrixFramesGenerator({ + required super.dimension, + math.Random? random, + }) : _random = random ?? math.Random(), + super(blur: 3); + + final math.Random _random; + + Frame? _previousFrame; + + @override + Frame generate({ + required int scale, + required int speed, + }) { + assert( + scale >= 1 && scale <= 255, + 'the scale value must be in range [1, 255]', + ); + + final previousFrame = _previousFrame; + + if (previousFrame == null) { + final frame = Frame( + dimension: dimension, + data: List.generate( + frameSize, + (_) => Colors.black, + growable: false, + ), + ); + + _previousFrame = frame; + + return frame; + } + + final data = List.from( + previousFrame.data, + growable: false, + ); + + // Fill the top row. + for (var x = 0; x < dimension; x++) { + final color = data[x].value; + + if (color == 0xFF000000) { + data[x] = _random.nextInt(scale) == 0 + ? const Color(0xFF00FF00) + : Colors.black; + } else if (color < 0xFF002000) { + data[x] = Colors.black; + } else { + data[x] = Color(color - 0x00002000); + } + } + + // Shift cells in the bottom direction. + for (var x = 0; x < dimension; x++) { + for (var y = dimension - 1; y > 0; y--) { + data[y * dimension + x] = data[(y - 1) * dimension + x]; + } + } + + final frame = Frame( + dimension: dimension, + data: data, + ); + + _previousFrame = frame; + + return frame; + } + + @override + void reset() { + _previousFrame = null; + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/noise_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/noise_frames_generator.dart new file mode 100644 index 0000000..0ac5fa3 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/noise_frames_generator.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/generators/fast_led/fast_led.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; + +/// {@template noise_frames_generator} +/// The base class for a generator that produces frames using Perlin noise. +/// {@endtemplate} +abstract class NoiseFramesGenerator extends FramesGenerator { + /// {@macro noise_frames_generator} + NoiseFramesGenerator({ + required super.dimension, + required this.palette, + required super.blur, + this.looped = false, + }) : assert(palette.length == 16, 'palette length must be equal 16') { + _init(); + } + + /// The list of 16 colors which will be used to produce frames. + /// + /// Each color have to be in RGB format. + /// The bits are interpreted as follows: + /// + /// * Bits 16-23 are the red value. + /// * Bits 8-15 are the green value. + /// * Bits 0-7 are the blue value. + final List palette; + + /// Whether the effect is looped. + final bool looped; + + int _x = 0; + + int _y = 0; + + int _z = 0; + + int _hue = 0; + + late List _noise; + + void _init() { + _x = 0; + _y = 0; + _z = 0; + _hue = 0; + + _noise = List.generate(frameSize, (_) => 0); + } + + @override + Frame generate({ + required int scale, + required int speed, + }) { + assert( + scale >= 1 && scale <= 255, + 'the scale value must be in range [1, 255]', + ); + + final dataSmoothing = speed < 50 ? (200 - (speed * 4)).toUnsigned(8) : 0; + + for (var i = 0; i < dimension; i++) { + final iOffset = scale * i; + + for (var j = 0; j < dimension; j++) { + final jOffset = scale * j; + + var data = FastLedNoise.inoise8(_x + iOffset, _y + jOffset, _z); + + data = FastLedMath.qsub8(data, 16); + data = FastLedMath.qadd8( + data, + FastLedMath.scale8(data, 39), + ); + + if (dataSmoothing != 0) { + final old = _noise[i * dimension + j]; + data = FastLedMath.scale8(old, dataSmoothing) + + FastLedMath.scale8(data, 256 - dataSmoothing); + } + + _noise[i * dimension + j] = data.toUnsigned(8); + } + } + + _z += speed; + _z = _z.toUnsigned(16); + + // apply slow drift to X and Y, just for visual variation. + _x += speed ~/ 8; + _x = _x.toUnsigned(16); + _y -= speed ~/ 16; + _y = _y.toUnsigned(16); + + final data = List.generate( + frameSize, + (_) => Colors.black, + growable: false, + ); + + for (var i = 0; i < dimension; i++) { + for (var j = 0; j < dimension; j++) { + var index = _noise[j * dimension + i]; + + if (looped) { + index += _hue; + } + + data[i * dimension + j] = Color( + FastLedColors.colorFromPalette( + palette: palette, + index: index, + brightness: 255, + ), + ); + } + } + + _hue += 1; + _hue = _hue.toUnsigned(8); + + return Frame( + dimension: dimension, + data: data, + ); + } + + @override + void reset() { + _init(); + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/ocean_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/ocean_frames_generator.dart new file mode 100644 index 0000000..6565470 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/ocean_frames_generator.dart @@ -0,0 +1,36 @@ +import 'package:gyver_lamp_effects/src/generators/fast_led/fast_led.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +/// {@template ocean_frames_generator} +/// A frames generator which produces ocean effect frames. +/// {@endtemplate} +class OceanFramesGenerator extends NoiseFramesGenerator { + /// {@macro ocean_frames_generator} + OceanFramesGenerator({ + required super.dimension, + }) : super( + blur: 20, + looped: true, + palette: const [ + FastLedColors.midnightBlue, + FastLedColors.darkBlue, + FastLedColors.midnightBlue, + FastLedColors.navy, + // + FastLedColors.darkBlue, + FastLedColors.mediumBlue, + FastLedColors.seaGreen, + FastLedColors.teal, + // + FastLedColors.cadetBlue, + FastLedColors.blue, + FastLedColors.darkCyan, + FastLedColors.cornflowerBlue, + // + FastLedColors.aquamarine, + FastLedColors.seaGreen, + FastLedColors.aqua, + FastLedColors.lightSkyBlue, + ], + ); +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/plasma_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/plasma_frames_generator.dart new file mode 100644 index 0000000..e5437fb --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/plasma_frames_generator.dart @@ -0,0 +1,34 @@ +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +/// {@template plasma_frames_generator} +/// A frames generator which produces plasma effect frames. +/// {@endtemplate} +class PlasmaFramesGenerator extends NoiseFramesGenerator { + /// {@macro plasma_frames_generator} + PlasmaFramesGenerator({ + required super.dimension, + }) : super( + blur: 20, + palette: const [ + 0x5500AB, + 0x84007C, + 0xB5004B, + 0xE5001B, + // + 0xE81700, + 0xB84700, + 0xAB7700, + 0xABAB00, + // + 0xAB5500, + 0xDD2200, + 0xF2000E, + 0xC2003E, + // + 0x8F0071, + 0x5F00A1, + 0x2F00D0, + 0x0007F9, + ], + ); +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/rainbow_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/rainbow_frames_generator.dart new file mode 100644 index 0000000..563d588 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/rainbow_frames_generator.dart @@ -0,0 +1,35 @@ +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +/// {@template rainbow_frames_generator} +/// A frames generator which produces rainbow effect frames. +/// {@endtemplate} +class RainbowFramesGenerator extends NoiseFramesGenerator { + /// {@macro rainbow_frames_generator} + RainbowFramesGenerator({ + required super.dimension, + }) : super( + blur: 20, + looped: true, + palette: const [ + 0xFF0000, + 0xD52A00, + 0xAB5500, + 0xAB7F00, + // + 0xABAB00, + 0x56D500, + 0x00FF00, + 0x00D52A, + // + 0x00AB55, + 0x0056AA, + 0x0000FF, + 0x2A00D5, + // + 0x5500AB, + 0x7F0081, + 0xAB0055, + 0xD5002B, + ], + ); +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/rainbow_stripes_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/rainbow_stripes_frames_generator.dart new file mode 100644 index 0000000..881f6da --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/rainbow_stripes_frames_generator.dart @@ -0,0 +1,35 @@ +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +/// {@template rainbow_stripes_frames_generator} +/// A frames generator which produces rainbow stripes effect frames. +/// {@endtemplate} +class RainbowStripesFramesGenerator extends NoiseFramesGenerator { + /// {@macro rainbow_stripes_frames_generator} + RainbowStripesFramesGenerator({ + required super.dimension, + }) : super( + blur: 20, + looped: true, + palette: const [ + 0xFF0000, + 0x000000, + 0xAB5500, + 0x000000, + // + 0xABAB00, + 0x000000, + 0x00FF00, + 0x000000, + // + 0x00AB55, + 0x000000, + 0x0000FF, + 0x000000, + // + 0x5500AB, + 0x000000, + 0xAB0055, + 0x000000, + ], + ); +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/snow_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/snow_frames_generator.dart new file mode 100644 index 0000000..63b07bf --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/snow_frames_generator.dart @@ -0,0 +1,86 @@ +import 'dart:math' as math show Random; + +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; + +/// {@template snow_frames_generator} +/// A frames generator which produces snow effect frames. +/// {@endtemplate} +final class SnowFramesGenerator extends FramesGenerator { + /// {@macro snow_frames_generator} + SnowFramesGenerator({ + required super.dimension, + math.Random? random, + }) : _random = random ?? math.Random(), + super(blur: 3); + + final math.Random _random; + + Frame? _previousFrame; + + @override + Frame generate({ + required int scale, + required int speed, + }) { + assert( + scale >= 1 && scale <= 255, + 'the scale value must be in range [1, 255]', + ); + + final previousFrame = _previousFrame; + + if (previousFrame == null || previousFrame.dimension != dimension) { + final frame = Frame( + dimension: dimension, + data: List.generate( + frameSize, + (_) => Colors.black, + growable: false, + ), + ); + + _previousFrame = frame; + + return frame; + } + + final data = List.from( + previousFrame.data, + growable: false, + ); + + // Shift all the flakes in the bottom direction. + for (var x = 0; x < dimension; x++) { + for (var y = dimension - 1; y > 0; y--) { + data[y * dimension + x] = data[(y - 1) * dimension + x]; + } + } + + // Randomly fill the top row with the snow flakes. + for (var x = 0; x < dimension; x++) { + if (data[dimension + x] == Colors.black && _random.nextInt(scale) == 0) { + data[x] = Color( + 0xFFE0FFFF - 0x00101010 * _random.nextInt(4), + ); + } else { + data[x] = Colors.black; + } + } + + final frame = Frame( + dimension: dimension, + data: data, + ); + + _previousFrame = frame; + + return frame; + } + + @override + void reset() { + _previousFrame = null; + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/sparkles_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/sparkles_frames_generator.dart new file mode 100644 index 0000000..c1a09ba --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/sparkles_frames_generator.dart @@ -0,0 +1,101 @@ +import 'dart:math' as math show Random; + +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/generators/fast_led/fast_led.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; + +/// {@template sparkles_frames_generator} +/// A frames generator which produces sparkles effect frames. +/// {@endtemplate} +final class SparklesFramesGenerator extends FramesGenerator { + /// {@macro sparkles_frames_generator} + SparklesFramesGenerator({ + required super.dimension, + math.Random? random, + }) : _random = random ?? math.Random(), + super(blur: 3); + + final math.Random _random; + + List? _sparkles; + + @override + Frame generate({ + required int scale, + required int speed, + }) { + assert( + scale >= 1 && scale <= 255, + 'the scale value must be in range [1, 255]', + ); + + final sparkles = _sparkles; + + if (sparkles == null) { + _sparkles = List.generate( + frameSize, + (_) => Colors.black, + growable: false, + ); + + return Frame( + dimension: dimension, + data: List.from(_sparkles!), + ); + } + + for (var i = 0; i < scale; i++) { + final x = _random.nextInt(dimension); + final y = _random.nextInt(dimension); + + final index = y * dimension + x; + + final sparkle = sparkles[index]; + + if (sparkle == Colors.black) { + sparkles[index] = HSLColor.fromAHSL( + 1, + _random.nextInt(360).toDouble(), + 1, + 0.6, + ).toColor(); + } + } + + final frame = Frame( + dimension: dimension, + data: List.from(sparkles), + ); + + for (var y = 0; y < dimension; y++) { + for (var x = 0; x < dimension; x++) { + final index = y * dimension + x; + + final color = sparkles[index]; + + final r = color.red; + final g = color.green; + final b = color.blue; + + if (r >= 30 || g >= 30 || b >= 30) { + sparkles[index] = Color.fromARGB( + color.alpha, + FastLedMath.scale8(r, 70), + FastLedMath.scale8(g, 70), + FastLedMath.scale8(b, 70), + ); + } else { + sparkles[index] = Colors.black; + } + } + } + + return frame; + } + + @override + void reset() { + _sparkles = null; + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/vertical_rainbow_frames_generator.dart b/packages/gyver_lamp_effects/lib/src/generators/vertical_rainbow_frames_generator.dart new file mode 100644 index 0000000..8063c77 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/vertical_rainbow_frames_generator.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/extensions/extensions.dart'; +import 'package:gyver_lamp_effects/src/generators/frames_generator.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; + +/// {@template vertical_rainbow_frames_generator} +/// A frames generator which produces vertical rainbow effect frames. +/// {@endtemplate} +final class VerticalRainbowFramesGenerator extends FramesGenerator { + /// {@macro vertical_rainbow_frames_generator} + VerticalRainbowFramesGenerator({ + required super.dimension, + }) : super(blur: 10); + + int _hue = 0; + + @override + Frame generate({ + required int scale, + required int speed, + }) { + assert( + scale >= 1 && scale <= 255, + 'the scale value must be in range [1, 255]', + ); + + _hue = (_hue + 2).toUnsigned(8); + + final data = List.generate( + frameSize, + (_) => Colors.black, + growable: false, + ); + + for (var y = 0; y < dimension; y++) { + final hue = + (_hue + (dimension - y) * scale).toUnsigned(8).remap(0, 255, 0, 360); + + final color = HSLColor.fromAHSL(1, hue, 1, 0.7).toColor(); + + for (var x = 0; x < dimension; x++) { + data[y * dimension + x] = color; + } + } + + return Frame( + dimension: dimension, + data: data, + ); + } + + @override + void reset() { + _hue = 0; + } +} diff --git a/packages/gyver_lamp_effects/lib/src/generators/zebra_frames_painter.dart b/packages/gyver_lamp_effects/lib/src/generators/zebra_frames_painter.dart new file mode 100644 index 0000000..b80e299 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/generators/zebra_frames_painter.dart @@ -0,0 +1,36 @@ +import 'package:gyver_lamp_effects/src/generators/fast_led/fast_led.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +/// {@template zebra_frames_generator} +/// A frames generator which produces zebra effect frames. +/// {@endtemplate} +class ZebraFramesGenerator extends NoiseFramesGenerator { + /// {@macro zebra_frames_generator} + ZebraFramesGenerator({ + required super.dimension, + }) : super( + blur: 20, + looped: true, + palette: const [ + FastLedColors.white, + FastLedColors.black, + FastLedColors.black, + FastLedColors.black, + // + FastLedColors.white, + FastLedColors.black, + FastLedColors.black, + FastLedColors.black, + // + FastLedColors.white, + FastLedColors.black, + FastLedColors.black, + FastLedColors.black, + // + FastLedColors.white, + FastLedColors.black, + FastLedColors.black, + FastLedColors.black, + ], + ); +} diff --git a/packages/gyver_lamp_effects/lib/src/models/frame.dart b/packages/gyver_lamp_effects/lib/src/models/frame.dart new file mode 100644 index 0000000..41abef0 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/models/frame.dart @@ -0,0 +1,34 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/widgets.dart'; + +/// {@template frame} +/// Representation of the one frame state of the effect. +/// +/// Each [Frame] contains [data] which is the the list of colors for each pixel +/// of the rectangular grid with side equal to [dimension]. +/// {@endtemplate} +final class Frame extends Equatable { + /// {@macro frame} + // ignore: prefer_const_constructors_in_immutables + Frame({ + required this.dimension, + required this.data, + }) : assert( + dimension > 0, + 'dimension must be greater that 0', + ), + assert( + data.length == dimension * dimension, + 'data length must be equal to dimension^2', + ); + + /// The length of the frame's side. + final int dimension; + + /// The list of colors for each pixel of the rectangular grid with the side + /// equal to [dimension]. + final List data; + + @override + List get props => [dimension, data]; +} diff --git a/packages/gyver_lamp_effects/lib/src/models/gyver_lamp_effect_type.dart b/packages/gyver_lamp_effects/lib/src/models/gyver_lamp_effect_type.dart new file mode 100644 index 0000000..a4517de --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/models/gyver_lamp_effect_type.dart @@ -0,0 +1,56 @@ +/// Represents the effect type. +enum GyverLampEffectType { + /// Represents a sparkles effect. + sparkles, + + /// Represents a fire effect. + fire, + + /// Represents a vertical rainbow effect. + verticalRainbow, + + /// Represents a horizontal rainbow effect. + horizontalRainbow, + + /// Represents a colors effect. + colors, + + /// Represents a madness effect. + madness, + + /// Represents a clouds effect. + clouds, + + /// Represents a lava effect. + lava, + + /// Represents a plasma effect. + plasma, + + /// Represents a rainbow effect. + rainbow, + + /// Represents a rainbow stripes effect. + rainbowStripes, + + /// Represents a zebra effect. + zebra, + + /// Represents a forest effect. + forest, + + /// Represents an ocean effect. + ocean, + + /// Represents a color effect. + color, + + /// Represents a snow effect. + snow, + + /// Represents a matrix effect. + matrix, + + /// Represents a fireflies effect. + fireflies, +} diff --git a/packages/gyver_lamp_effects/lib/src/models/models.dart b/packages/gyver_lamp_effects/lib/src/models/models.dart new file mode 100644 index 0000000..41f4d19 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/models/models.dart @@ -0,0 +1,2 @@ +export 'frame.dart'; +export 'gyver_lamp_effect_type.dart'; diff --git a/packages/gyver_lamp_effects/lib/src/widgets/fidgeter.dart b/packages/gyver_lamp_effects/lib/src/widgets/fidgeter.dart new file mode 100644 index 0000000..053cb3b --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/widgets/fidgeter.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/extensions/extensions.dart'; + +/// The maximal rotation in radians. +const kMaxRotation = 0.1; + +/// {@template fidgeter} +/// Widget which adds interactivity to the [child]. Interactivity means, that +/// [child] starts to slightly rotate according to the taps on the sides. +/// {@endtemplate} +class Fidgeter extends StatefulWidget { + /// {@macro fidgeter} + const Fidgeter({ + required this.child, + this.foregroundChild, + super.key, + }); + + /// The child widget which will be rotated. + final Widget child; + + /// The widget which will be painted above the [child]. + final Widget? foregroundChild; + + @override + State createState() => _FidgeterState(); +} + +class _FidgeterState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + duration: const Duration(milliseconds: 150), + vsync: this, + ); + + static const _startPosition = Offset(0.5, 0.5); + + final _tapCenterTween = Tween( + begin: _startPosition, + end: _startPosition, + ); + + Offset get _tapCenter => _tapCenterTween + .chain(CurveTween(curve: Curves.easeInOut)) + .evaluate(_controller); + + Matrix4 get _transformation { + final tapCenter = _tapCenter; + + final xRotation = tapCenter.dy.remap(0, 1, -kMaxRotation, kMaxRotation); + final yRotation = tapCenter.dx.remap(0, 1, kMaxRotation, -kMaxRotation); + + return Matrix4.identity() + ..setEntry(3, 2, 0.005) + ..multiply(Matrix4.rotationX(xRotation)) + ..multiply(Matrix4.rotationY(yRotation)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _transform({ + required Offset localPosition, + required Size size, + }) { + _tapCenterTween + ..begin = _tapCenterTween.end + ..end = Offset( + (localPosition.dx / size.width).clamp(0, 1), + (localPosition.dy / size.height).clamp(0, 1), + ); + + _controller + ..reset() + ..forward(); + } + + void _reset() { + if (_tapCenterTween.end == _startPosition) { + return; + } + + _tapCenterTween + ..begin = _tapCenterTween.end + ..end = _startPosition; + + _controller + ..reset() + ..forward(); + } + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform( + alignment: Alignment.center, + transform: _transformation, + child: child, + ); + }, + child: Stack( + children: [ + Positioned.fill( + child: LayoutBuilder( + builder: (context, constraints) { + final size = constraints.biggest; + + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTapDown: (d) => _transform( + localPosition: d.localPosition, + size: size, + ), + onPanStart: (d) => _transform( + localPosition: d.localPosition, + size: size, + ), + onPanUpdate: (d) => _transform( + localPosition: d.localPosition, + size: size, + ), + onPanCancel: _reset, + onPanEnd: (_) => _reset(), + onTapUp: (_) => _reset(), + behavior: HitTestBehavior.opaque, + child: widget.child, + ), + ); + }, + ), + ), + if (widget.foregroundChild != null) widget.foregroundChild!, + ], + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_effects/lib/src/widgets/frame_painter.dart b/packages/gyver_lamp_effects/lib/src/widgets/frame_painter.dart new file mode 100644 index 0000000..c1f88ab --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/widgets/frame_painter.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; + +/// {@template frame_painter} +/// Widget which paints [frame] as a square of pixels. +/// {@endtemplate} +class FramePainter extends StatelessWidget { + /// {@macro frame_painter} + const FramePainter({ + required this.frame, + super.key, + }); + + /// The frame which will be painted. + final Frame frame; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _FrameCustomPainter(frame: frame), + ); + } +} + +class _FrameCustomPainter extends CustomPainter { + const _FrameCustomPainter({required this.frame}); + + final Frame frame; + + @override + void paint(Canvas canvas, Size size) { + final side = size.shortestSide / frame.dimension; + + for (var y = 0; y < frame.dimension; y++) { + final top = y * side; + + for (var x = 0; x < frame.dimension; x++) { + final left = x * side; + + final r = Rect.fromLTWH(left, top, side, side); + + canvas.drawRect( + r, + Paint() + ..color = frame.data[y * frame.dimension + x] + ..isAntiAlias = false, + ); + } + } + } + + @override + bool shouldRepaint(_FrameCustomPainter oldDelegate) { + return oldDelegate.frame != frame; + } +} diff --git a/packages/gyver_lamp_effects/lib/src/widgets/frames_builder.dart b/packages/gyver_lamp_effects/lib/src/widgets/frames_builder.dart new file mode 100644 index 0000000..3f1fa4b --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/widgets/frames_builder.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; +import 'package:gyver_lamp_effects/src/widgets/widgets.dart'; + +/// {@template frames_builder} +/// The widget which visualizes frames built by [FramesGenerator]. +/// {@endtemplate} +class FramesBuilder extends StatefulWidget { + /// {@macro frames_builder} + const FramesBuilder({ + required this.generator, + required this.speed, + required this.scale, + required this.paused, + super.key, + }); + + /// The generator to get frames from. + final FramesGenerator generator; + + /// The speed value which will be passed to the [generator]. + final int speed; + + /// The scale value which will be passed to the [generator]. + final int scale; + + /// Whether builder is paused. + final bool paused; + + @override + State createState() => _FramesBuilderState(); +} + +class _FramesBuilderState extends State { + Timer? _timer; + + late Frame _frame; + + @override + void initState() { + super.initState(); + + if (widget.paused) { + widget.generator.reset(); + } + + _frame = widget.generator.generate( + speed: widget.speed, + scale: widget.scale, + ); + + if (widget.paused) { + return; + } + + _startTimer(); + } + + @override + void didUpdateWidget(FramesBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + + if ((oldWidget.generator != widget.generator || + oldWidget.scale != widget.scale) && + widget.paused) { + _frame = widget.generator.generate( + speed: widget.speed, + scale: widget.scale, + ); + } + + if (oldWidget.paused != widget.paused) { + if (widget.paused) { + // If builder is paused then we just cancel timer and stop animation + // without care of speed change, etc. + _cancelTimer(); + return; + } + + _startTimer(); + } + + if (oldWidget.speed != widget.speed && !widget.paused) { + _startTimer(); + } + } + + @override + void dispose() { + _cancelTimer(); + super.dispose(); + } + + void _startTimer() { + _cancelTimer(); + + _timer = Timer.periodic( + Duration(milliseconds: widget.speed), + (timer) { + if (!timer.isActive || !mounted) { + return; + } + + setState(() { + _frame = widget.generator.generate( + speed: widget.speed, + scale: widget.scale, + ); + }); + }, + ); + } + + void _cancelTimer() { + _timer?.cancel(); + _timer = null; + } + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: FramePainter(frame: _frame), + ); + } +} diff --git a/packages/gyver_lamp_effects/lib/src/widgets/gyver_lamp_effect.dart b/packages/gyver_lamp_effects/lib/src/widgets/gyver_lamp_effect.dart new file mode 100644 index 0000000..14251f2 --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/widgets/gyver_lamp_effect.dart @@ -0,0 +1,172 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; +import 'package:gyver_lamp_effects/src/widgets/widgets.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +/// {@template gyver_lamp_effect} +/// The widget which visualizes [GyverLampEffectType] effect. +/// {@endtemplate} +class GyverLampEffect extends StatefulWidget { + /// {@macro gyver_lamp_effect} + const GyverLampEffect({ + required this.type, + required this.speed, + required this.scale, + this.dimension = 16, + super.key, + }); + + /// The type of effect to show. + final GyverLampEffectType type; + + /// The speed of the effect. + final int speed; + + /// The scale of the effect. + final int scale; + + /// The dimension of the effect frame. + final int dimension; + + @override + State createState() => _GyverLampEffectState(); +} + +class _GyverLampEffectState extends State { + static const _generators = { + GyverLampEffectType.sparkles: SparklesFramesGenerator.new, + GyverLampEffectType.fire: FireFramesGenerator.new, + GyverLampEffectType.verticalRainbow: VerticalRainbowFramesGenerator.new, + GyverLampEffectType.horizontalRainbow: HorizontalRainbowFramesGenerator.new, + GyverLampEffectType.colors: ColorsFramesGenerator.new, + GyverLampEffectType.madness: MadnessFramesGenerator.new, + GyverLampEffectType.clouds: CloudsFramesGenerator.new, + GyverLampEffectType.lava: LavaFramesGenerator.new, + GyverLampEffectType.plasma: PlasmaFramesGenerator.new, + GyverLampEffectType.rainbow: RainbowFramesGenerator.new, + GyverLampEffectType.rainbowStripes: RainbowStripesFramesGenerator.new, + GyverLampEffectType.zebra: ZebraFramesGenerator.new, + GyverLampEffectType.forest: ForestFramesGenerator.new, + GyverLampEffectType.ocean: OceanFramesGenerator.new, + GyverLampEffectType.color: ColorFramesGenerator.new, + GyverLampEffectType.snow: SnowFramesGenerator.new, + GyverLampEffectType.matrix: MatrixFramesGenerator.new, + GyverLampEffectType.fireflies: FirefliesFramesGenerator.new, + }; + + final ValueNotifier _paused = ValueNotifier(true); + + late FramesGenerator _generator; + + @override + void initState() { + super.initState(); + _generator = _generators[widget.type]!(dimension: widget.dimension); + } + + @override + void didUpdateWidget(GyverLampEffect oldWidget) { + if (oldWidget.type != widget.type) { + _generator = _generators[widget.type]!(dimension: widget.dimension); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _paused.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return Fidgeter( + foregroundChild: Positioned( + bottom: GyverLampSpacings.md, + right: GyverLampSpacings.md, + child: ValueListenableBuilder( + valueListenable: _paused, + builder: (context, paused, _) { + return FlatIconButton.large( + icon: paused ? GyverLampIcons.play : GyverLampIcons.pause, + onPressed: () { + _paused.value = !paused; + }, + ); + }, + ), + ), + child: DecoratedBox( + decoration: BoxDecoration( + boxShadow: [theme.shadows.shadow4], + ), + child: ClipRRect( + borderRadius: const BorderRadius.all( + Radius.circular(30), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + reverseDuration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeIn, + switchOutCurve: Curves.easeOut, + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: child, + ); + }, + layoutBuilder: ( + Widget? currentChild, + List previousChildren, + ) { + return Stack( + alignment: Alignment.center, + children: [ + ...previousChildren.map( + (e) => Positioned.fill(child: e), + ), + if (currentChild != null) + Positioned.fill(child: currentChild), + ], + ); + }, + child: Transform.scale( + key: ValueKey(widget.type), + scale: 1.1, + child: ImageFiltered( + imageFilter: ui.ImageFilter.compose( + outer: ui.ImageFilter.blur( + sigmaX: _generator.blur, + sigmaY: _generator.blur, + tileMode: TileMode.mirror, + ), + inner: ui.ColorFilter.mode( + Colors.white.withOpacity(0.05), + BlendMode.lighten, + ), + ), + child: ValueListenableBuilder( + valueListenable: _paused, + builder: (context, paused, _) { + return FramesBuilder( + generator: _generator, + speed: widget.speed, + scale: widget.scale, + paused: paused, + ); + }, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_effects/lib/src/widgets/widgets.dart b/packages/gyver_lamp_effects/lib/src/widgets/widgets.dart new file mode 100644 index 0000000..080c2ef --- /dev/null +++ b/packages/gyver_lamp_effects/lib/src/widgets/widgets.dart @@ -0,0 +1,4 @@ +export 'fidgeter.dart'; +export 'frame_painter.dart'; +export 'frames_builder.dart'; +export 'gyver_lamp_effect.dart'; diff --git a/packages/gyver_lamp_effects/pubspec.yaml b/packages/gyver_lamp_effects/pubspec.yaml new file mode 100644 index 0000000..0b0fd59 --- /dev/null +++ b/packages/gyver_lamp_effects/pubspec.yaml @@ -0,0 +1,23 @@ +name: gyver_lamp_effects +description: Gyver Lamp Effects +version: 1.0.0 +publish_to: none + +environment: + sdk: '>=3.0.5 <4.0.0' + flutter: ">=3.10.0" + +dependencies: + equatable: ^2.0.5 + flutter: + sdk: flutter + gyver_lamp_icons: + path: ../gyver_lamp_icons + gyver_lamp_ui: + path: ../gyver_lamp_ui + +dev_dependencies: + flutter_test: + sdk: flutter + mocktail: 1.0.1 + very_good_analysis: 5.1.0 diff --git a/packages/gyver_lamp_effects/test/extensions/remap_test.dart b/packages/gyver_lamp_effects/test/extensions/remap_test.dart new file mode 100644 index 0000000..008bdd5 --- /dev/null +++ b/packages/gyver_lamp_effects/test/extensions/remap_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/extensions/extensions.dart'; + +void main() { + group('NumRemap', () { + test( + 'remaps positive number to the new range', + () { + expect( + 10.remap(0, 20, 0, 10), + equals(5.0), + ); + }, + ); + + test( + 'remaps negative number to the new range', + () { + expect( + -10.0.remap(-20, 0, -10, 0), + equals(-5.0), + ); + }, + ); + + test( + 'lefts number unchanged if the ranges are the same', + () { + expect( + 10.0.remap(0, 20, 0, 20), + equals(10.0), + ); + }, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/clouds_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/clouds_frames_generator_test.dart new file mode 100644 index 0000000..f386aed --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/clouds_frames_generator_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('CloudsFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(CloudsFramesGenerator(dimension: 16).blur, equals(20)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = CloudsFramesGenerator(dimension: 16); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = CloudsFramesGenerator(dimension: settings.dimension); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/clouds_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/color_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/color_frames_generator_test.dart new file mode 100644 index 0000000..004825b --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/color_frames_generator_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('ColorFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(ColorFramesGenerator(dimension: 16).blur, equals(0)); + }); + + test('caches frame until scale changes', () { + final generator = ColorFramesGenerator(dimension: 16); + + final frame = generator.generate(speed: 8, scale: 33); + + expect( + identical(frame, generator.generate(speed: 8, scale: 33)), + isTrue, + ); + expect( + identical(frame, generator.generate(speed: 8, scale: 11)), + isFalse, + ); + }); + + test('caches frame even if speed changes', () { + final generator = ColorFramesGenerator(dimension: 16); + + final frame = generator.generate(speed: 8, scale: 33); + + expect( + identical(frame, generator.generate(speed: 8, scale: 33)), + isTrue, + ); + expect( + identical(frame, generator.generate(speed: 16, scale: 33)), + isTrue, + ); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = ColorFramesGenerator(dimension: 16); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..reset(); + + final initialFrameAfterReset = generator.generate( + speed: speed, + scale: scale, + ); + + expect(identical(initialFrame, initialFrameAfterReset), isFalse); + expect(initialFrame, equals(initialFrameAfterReset)); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = ColorFramesGenerator(dimension: settings.dimension); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/color_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/colors_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/colors_frames_generator_test.dart new file mode 100644 index 0000000..89e1596 --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/colors_frames_generator_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('ColorsFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(ColorsFramesGenerator(dimension: 16).blur, equals(0)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = ColorsFramesGenerator(dimension: 16); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = ColorsFramesGenerator(dimension: settings.dimension); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/colors_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/fast_led/fast_led_colors_test.dart b/packages/gyver_lamp_effects/test/generators/fast_led/fast_led_colors_test.dart new file mode 100644 index 0000000..1eab97b --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/fast_led/fast_led_colors_test.dart @@ -0,0 +1,251 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/fast_led/fast_led.dart'; + +void main() { + const palette = [ + 0xFF0000, + 0x000000, + 0xAB5500, + 0x000000, + // + 0xABAB00, + 0x000000, + 0x00FF00, + 0x000000, + // + 0x00AB55, + 0x000000, + 0x0000FF, + 0x000000, + // + 0x5500AB, + 0x000000, + 0xAB0055, + 0x000000, + ]; + + group('FastLedColors', () { + group('colorFromPalette', () { + test('throws AssertionError when pallette has wrong length', () async { + expect( + () => FastLedColors.colorFromPalette( + palette: [1, 2, 3], + index: 0, + brightness: 255, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals('palette have to contain 16 entries'), + ), + ), + ); + }); + + group('returns right colors', () { + test('for test palette', () { + expect( + FastLedColors.colorFromPalette( + palette: palette, + index: 0, + brightness: 255, + ), + equals(0xFFFF0000), + ); + + expect( + FastLedColors.colorFromPalette( + palette: palette, + index: 128, + brightness: 255, + ), + equals(0xFF00AB55), + ); + + expect( + FastLedColors.colorFromPalette( + palette: palette, + index: 255, + brightness: 255, + ), + equals(0xFFF00000), + ); + }); + + test('for ocean palette', () { + const oceanPalette = [ + FastLedColors.midnightBlue, + FastLedColors.darkBlue, + FastLedColors.midnightBlue, + FastLedColors.navy, + // + FastLedColors.darkBlue, + FastLedColors.mediumBlue, + FastLedColors.seaGreen, + FastLedColors.teal, + // + FastLedColors.cadetBlue, + FastLedColors.blue, + FastLedColors.darkCyan, + FastLedColors.cornflowerBlue, + // + FastLedColors.aquamarine, + FastLedColors.seaGreen, + FastLedColors.aqua, + FastLedColors.lightSkyBlue, + ]; + + expect( + FastLedColors.colorFromPalette( + palette: oceanPalette, + index: 0, + brightness: 255, + ), + equals(0xFF191970), + ); + + expect( + FastLedColors.colorFromPalette( + palette: oceanPalette, + index: 128, + brightness: 255, + ), + equals(0xFF5F9EA0), + ); + + expect( + FastLedColors.colorFromPalette( + palette: oceanPalette, + index: 255, + brightness: 255, + ), + equals(0xFF1F2378), + ); + }); + + test('for lava palette', () { + const lavaPalette = [ + FastLedColors.black, + FastLedColors.maroon, + FastLedColors.black, + FastLedColors.maroon, + // + FastLedColors.darkRed, + FastLedColors.darkRed, + FastLedColors.maroon, + FastLedColors.darkRed, + // + FastLedColors.darkRed, + FastLedColors.darkRed, + FastLedColors.red, + FastLedColors.orange, + // + FastLedColors.white, + FastLedColors.orange, + FastLedColors.red, + FastLedColors.darkRed, + ]; + + expect( + FastLedColors.colorFromPalette( + palette: lavaPalette, + index: 0, + brightness: 255, + ), + equals(0xFF000000), + ); + + expect( + FastLedColors.colorFromPalette( + palette: lavaPalette, + index: 33, + brightness: 255, + ), + equals(0xFF080000), + ); + + expect( + FastLedColors.colorFromPalette( + palette: lavaPalette, + index: 235, + brightness: 255, + ), + equals(0xFFAF0000), + ); + }); + }); + + test('cycles index to be in range [0, 255]', () async { + expect( + FastLedColors.colorFromPalette( + palette: palette, + index: 0, + brightness: 255, + ), + FastLedColors.colorFromPalette( + palette: palette, + index: 256, + brightness: 255, + ), + ); + }); + + test('respects brightness', () async { + expect( + FastLedColors.colorFromPalette( + palette: palette, + index: 111, + brightness: 0, + ), + equals(0xFF000000), + ); + + expect( + FastLedColors.colorFromPalette( + palette: palette, + index: 111, + brightness: 128, + ), + equals(0xFF000700), + ); + + expect( + FastLedColors.colorFromPalette( + palette: palette, + index: 111, + brightness: 255, + ), + equals(0xFF000F00), + ); + + expect( + FastLedColors.colorFromPalette( + palette: palette, + index: 233, + brightness: 0, + ), + equals(0xFF000000), + ); + + expect( + FastLedColors.colorFromPalette( + palette: palette, + index: 233, + brightness: 128, + ), + equals(0xFF250012), + ); + + expect( + FastLedColors.colorFromPalette( + palette: palette, + index: 233, + brightness: 255, + ), + equals(0xFF4A0025), + ); + }); + }); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/fast_led/fast_led_math_test.dart b/packages/gyver_lamp_effects/test/generators/fast_led/fast_led_math_test.dart new file mode 100644 index 0000000..dfa2d30 --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/fast_led/fast_led_math_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/fast_led/fast_led.dart'; + +void main() { + group('FastLedMath', () { + group('qadd8', () { + test('returns sum of the two integers', () { + expect(FastLedMath.qadd8(3, 4), equals(7)); + }); + + test('returns 255 if sum is greater than 255', () { + expect(FastLedMath.qadd8(255, 1), equals(255)); + }); + }); + + group('qsub8', () { + test('returns subtraction result of the two integers', () { + expect(FastLedMath.qsub8(4, 3), equals(1)); + }); + + test('returns 0 if subtraction result is lower than 0', () { + expect(FastLedMath.qsub8(1, 3), equals(0)); + }); + }); + + group('avg7', () { + test('returns average of the two integers', () { + expect(FastLedMath.avg7(4, 2), equals(3)); + }); + + test('rounds result up when first number is odd', () { + expect(FastLedMath.avg7(3, 4), equals(4)); + }); + + test('rounds result down when first number is even', () { + expect(FastLedMath.avg7(4, 3), equals(3)); + }); + }); + + group('scale8', () { + test('scales number by scale', () { + expect(FastLedMath.scale8(4, 0), equals(0)); + expect(FastLedMath.scale8(4, 64), equals(1)); + expect(FastLedMath.scale8(4, 128), equals(2)); + expect(FastLedMath.scale8(4, 255), equals(4)); + }); + }); + + group('dim8_raw', () { + test('scales number by itself', () { + expect(FastLedMath.dim8_raw(0), equals(0)); + expect(FastLedMath.dim8_raw(64), equals(16)); + expect(FastLedMath.dim8_raw(128), equals(64)); + expect(FastLedMath.dim8_raw(255), equals(255)); + }); + }); + + group('lerp7by8', () { + test('interpolates between two integers when a > b', () { + expect(FastLedMath.lerp7by8(0, 10, 0), equals(0)); + expect(FastLedMath.lerp7by8(0, 10, 128), equals(5)); + expect(FastLedMath.lerp7by8(0, 10, 255), equals(10)); + }); + + test('interpolates between two integers when b > a', () { + expect(FastLedMath.lerp7by8(10, 0, 0), equals(10)); + expect(FastLedMath.lerp7by8(10, 0, 128), equals(5)); + expect(FastLedMath.lerp7by8(10, 0, 255), equals(0)); + }); + }); + + group('ease8InOutQuad', () { + test('applies easeInOutQuad curve to the input', () { + expect(FastLedMath.ease8InOutQuad(0), equals(0)); + expect(FastLedMath.ease8InOutQuad(64), equals(32)); + expect(FastLedMath.ease8InOutQuad(128), equals(129)); + expect(FastLedMath.ease8InOutQuad(192), equals(225)); + expect(FastLedMath.ease8InOutQuad(255), equals(255)); + }); + }); + + group('lsrX4', () { + test('divides number by 16', () { + expect(FastLedMath.lsrX4(1), equals(0)); + expect(FastLedMath.lsrX4(16), equals(1)); + expect(FastLedMath.lsrX4(32), equals(2)); + expect(FastLedMath.lsrX4(64), equals(4)); + expect(FastLedMath.lsrX4(128), equals(8)); + expect(FastLedMath.lsrX4(133), equals(8)); + expect(FastLedMath.lsrX4(255), equals(15)); + }); + }); + + group('grad8', () { + test('generates noise', () { + expect(FastLedMath.grad8(1, 33, 11, 77), equals(-10)); + expect(FastLedMath.grad8(12, 12, 6, 4), equals(9)); + expect(FastLedMath.grad8(32, 3, 90, 22), equals(47)); + expect(FastLedMath.grad8(64, 111, 23, 44), equals(68)); + expect(FastLedMath.grad8(128, 55, 21, 123), equals(39)); + expect(FastLedMath.grad8(255, 53, 33, 56), equals(-44)); + }); + }); + + group('selectBasedOnHashBit', () { + test('selects number based on hash bit', () { + final number = int.parse('10101010', radix: 2); + + expect(FastLedMath.selectBasedOnHashBit(number, 0, 1, 2), equals(2)); + expect(FastLedMath.selectBasedOnHashBit(number, 1, 1, 2), equals(1)); + expect(FastLedMath.selectBasedOnHashBit(number, 2, 1, 2), equals(2)); + expect(FastLedMath.selectBasedOnHashBit(number, 3, 1, 2), equals(1)); + expect(FastLedMath.selectBasedOnHashBit(number, 4, 1, 2), equals(2)); + expect(FastLedMath.selectBasedOnHashBit(number, 5, 1, 2), equals(1)); + expect(FastLedMath.selectBasedOnHashBit(number, 6, 1, 2), equals(2)); + expect(FastLedMath.selectBasedOnHashBit(number, 7, 1, 2), equals(1)); + }); + }); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/fast_led/fast_led_noise_test.dart b/packages/gyver_lamp_effects/test/generators/fast_led/fast_led_noise_test.dart new file mode 100644 index 0000000..d0e54d7 --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/fast_led/fast_led_noise_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/fast_led/fast_led.dart'; + +void main() { + group('FastLedNoise', () { + test('inoise8 generates correct value', () { + expect(FastLedNoise.inoise8(0, 0, 0), equals(128)); + + expect(FastLedNoise.inoise8(16, 0, 0), equals(136)); + expect(FastLedNoise.inoise8(0, 16, 0), equals(128)); + expect(FastLedNoise.inoise8(0, 0, 16), equals(136)); + expect(FastLedNoise.inoise8(16, 16, 16), equals(144)); + + expect(FastLedNoise.inoise8(32, 0, 0), equals(140)); + expect(FastLedNoise.inoise8(0, 32, 0), equals(126)); + expect(FastLedNoise.inoise8(0, 0, 32), equals(146)); + expect(FastLedNoise.inoise8(32, 32, 32), equals(152)); + + expect(FastLedNoise.inoise8(64, 0, 0), equals(144)); + expect(FastLedNoise.inoise8(0, 64, 0), equals(116)); + expect(FastLedNoise.inoise8(0, 0, 64), equals(168)); + expect(FastLedNoise.inoise8(64, 64, 64), equals(154)); + + expect(FastLedNoise.inoise8(128, 0, 0), equals(128)); + expect(FastLedNoise.inoise8(0, 128, 0), equals(96)); + expect(FastLedNoise.inoise8(0, 0, 128), equals(192)); + expect(FastLedNoise.inoise8(128, 128, 128), equals(96)); + + expect(FastLedNoise.inoise8(192, 0, 0), equals(112)); + expect(FastLedNoise.inoise8(0, 192, 0), equals(100)); + expect(FastLedNoise.inoise8(0, 0, 192), equals(168)); + expect(FastLedNoise.inoise8(192, 192, 192), equals(144)); + + expect(FastLedNoise.inoise8(255, 0, 0), equals(128)); + expect(FastLedNoise.inoise8(0, 255, 0), equals(128)); + expect(FastLedNoise.inoise8(0, 0, 255), equals(128)); + expect(FastLedNoise.inoise8(255, 255, 255), equals(132)); + }); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/fire_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/fire_frames_generator_test.dart new file mode 100644 index 0000000..8507e88 --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/fire_frames_generator_test.dart @@ -0,0 +1,77 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('FireFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(FireFramesGenerator(dimension: 16).blur, equals(5)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = FireFramesGenerator( + dimension: 16, + random: math.Random(3333), + ); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = FireFramesGenerator( + dimension: settings.dimension, + random: math.Random(3333), + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile('goldens/fire_frames_generator.${settings.id}.png'), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/fireflies_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/fireflies_frames_generator_test.dart new file mode 100644 index 0000000..1f48812 --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/fireflies_frames_generator_test.dart @@ -0,0 +1,51 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('FirefliesFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(FirefliesFramesGenerator(dimension: 16).blur, equals(3)); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = FirefliesFramesGenerator( + dimension: settings.dimension, + random: math.Random(3333), + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/fireflies_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/forest_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/forest_frames_generator_test.dart new file mode 100644 index 0000000..79a1526 --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/forest_frames_generator_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('ForestFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(ForestFramesGenerator(dimension: 16).blur, equals(20)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = ForestFramesGenerator(dimension: 16); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = ForestFramesGenerator(dimension: settings.dimension); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/forest_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..db57ceb Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..01b97ed Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..a284cb3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..d2386a9 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..cc6dd49 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..157e559 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..064b1cb Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..0b80e9f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..04f8169 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..2baad1e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..ea75742 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..84f1e55 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..aa8c916 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..4d59421 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..97ad034 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..e965be6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..f6f1c29 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..ea7ac60 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..e03ab04 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..b77b3ff Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..dda0d75 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..7ce4a38 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..2ab29f2 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..43e6839 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..7a7b4da Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/clouds_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..0c5f8d8 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..8a5ad2e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..e992c4b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..903b612 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..eab6341 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..0c5f8d8 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..8a5ad2e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..e992c4b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..903b612 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..eab6341 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..0c5f8d8 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..8a5ad2e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..e992c4b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..903b612 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..eab6341 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..0c5f8d8 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..8a5ad2e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..e992c4b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..903b612 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..eab6341 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..0c5f8d8 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..8a5ad2e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..e992c4b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..903b612 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..eab6341 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/color_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..a603a43 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..75f3b99 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..7980cce Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..73626f3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..1434274 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..a603a43 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..75f3b99 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..7980cce Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..73626f3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..1434274 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..a603a43 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..75f3b99 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..7980cce Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..73626f3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..1434274 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..a603a43 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..75f3b99 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..7980cce Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..73626f3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..1434274 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..a603a43 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..75f3b99 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..7980cce Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..73626f3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..1434274 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/colors_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..ea55acd Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..b82ee85 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..5449c6a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..0a1ad04 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..3b4ed98 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..ea55acd Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..b82ee85 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..5449c6a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..0a1ad04 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..3b4ed98 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..ea55acd Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..b82ee85 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..5449c6a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..0a1ad04 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..3b4ed98 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..ea55acd Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..b82ee85 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..5449c6a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..0a1ad04 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..3b4ed98 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..ea55acd Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..b82ee85 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..5449c6a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..0a1ad04 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..3b4ed98 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fire_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..6dda1ff Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..6256b9f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..6256b9f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..827c6e6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..aaa6dc4 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..6dda1ff Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..6256b9f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..6256b9f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..827c6e6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..aaa6dc4 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..6dda1ff Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..6256b9f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..6256b9f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..827c6e6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..aaa6dc4 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..6dda1ff Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..6256b9f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..6256b9f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..827c6e6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..aaa6dc4 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..6dda1ff Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..6256b9f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..6256b9f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..827c6e6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..aaa6dc4 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/fireflies_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..17fe7ce Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..0717ef7 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..17a73c9 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..d543165 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..6093c04 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..924eb3c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..04537f2 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..432a04c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..cafcb01 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..3793e68 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..3295140 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..355c9a5 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..d77513f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..6885d70 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..c7cefe5 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..9171375 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..82dba05 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..2acf803 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..e2a2ea6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..d32064c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..4f5f2ca Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..45f01d3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..91b7ef4 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..365c799 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..0d08cd8 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/forest_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..8cd8c4f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..8ce732b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..d1a14ea Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..5237e76 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..38b2006 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..8cd8c4f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..8ce732b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..d1a14ea Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..5237e76 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..38b2006 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..8cd8c4f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..8ce732b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..d1a14ea Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..5237e76 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..38b2006 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..8cd8c4f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..8ce732b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..d1a14ea Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..5237e76 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..38b2006 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..8cd8c4f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..8ce732b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..d1a14ea Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..5237e76 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..38b2006 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/horizontal_rainbow_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..6743300 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..bebd26c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..6305877 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..08dc947 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..4e2832f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..4cb0392 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..46c6813 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..23a799e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..b85302a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..635b4c7 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..32a9bd1 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..916d468 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..fbc8e4d Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..1d559fb Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..036ae4c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..aad4519 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..5ff96e2 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..2b87df4 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..4ead156 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..a8532fd Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..5d23b78 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..8f72737 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..323bed1 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..31d8193 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..dab9d65 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/lava_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..332c9fd Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..98631d7 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..8f0d4e3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..2e0ca65 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..205390a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..b69e7f3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..c5655c4 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..eee67c4 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..f263021 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..54c52df Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..77a807e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..c225307 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..8548cc2 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..44ad628 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..107f8bd Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..ba2f5d1 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..39beaef Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..2590cc4 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..712ffd6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..4066334 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..2471cdd Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..a1afff9 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..f4d97ec Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..80e7014 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..7726db7 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/madness_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..f92ec18 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..6c3b20c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..5260c9b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..a6777da Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..2d93949 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..f92ec18 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..6c3b20c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..5260c9b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..a6777da Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..2d93949 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..f92ec18 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..6c3b20c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..5260c9b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..a6777da Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..2d93949 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..f92ec18 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..6c3b20c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..5260c9b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..a6777da Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..2d93949 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..f92ec18 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..6c3b20c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..5260c9b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..a6777da Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..2d93949 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/matrix_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..404c8e0 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..70e5bf7 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..48d391f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..3e2af9f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..249eea3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..80dd7cf Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..13a0826 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..7b85e25 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..07bbe78 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..2ad5c83 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..ead2b26 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..507729b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..dbc7a25 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..6df7d57 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..11bd34e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..3a1bcf0 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..284b18c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..5b0516f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..9b5f406 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..9db4f98 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..a6180a6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..fb55467 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..1c183d2 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..eb8232e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..b48167e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/ocean_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..b0431de Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..151466e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..e2de694 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..ed96258 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..7553355 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..87e67a7 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..1fff2f8 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..6609455 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..3106154 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..44795e7 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..ab71819 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..f4d1f56 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..f20b488 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..20c98a6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..51baeda Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..eab52b5 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..fc9dba6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..2a4fb78 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..5b7dc65 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..a36edae Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..924a0b6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..8d68136 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..ef36b5e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..8ed22f0 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..147fb6f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/plasma_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..18f6e34 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..369fe99 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..2cd512f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..942beea Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..76e1e35 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..fbf9050 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..21f0e4c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..6c75e8f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..997ce32 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..1ee0e5c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..ef30218 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..2f83b64 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..4310458 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..63cb0a1 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..91424b3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..160981b Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..ecbecb1 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..56e4963 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..134701f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..0743876 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..b88dc5d Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..0efed13 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..86cf740 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..7a90d86 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..8699388 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..2b7798c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..9754f1e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..f7d169a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..caad874 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..cd5fa6f Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..0cbd40d Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..6476e83 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..1e84ac5 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..f69e536 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..7e9f663 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..29af66e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..a85a0b0 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..dad7212 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..769b80e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..073a4d3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..6d7e7b9 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..d4a77d6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..ec320aa Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..60d3062 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..446b6a9 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..89d0bde Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..3c995ce Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..8c4eeb0 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..05ead25 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..6b7fd38 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/rainbow_stripes_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..f6d43e2 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..0ce5bfe Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..25a13ff Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..b75126a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..60770c5 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..f6d43e2 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..0ce5bfe Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..25a13ff Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..b75126a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..60770c5 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..f6d43e2 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..0ce5bfe Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..25a13ff Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..b75126a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..60770c5 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..f6d43e2 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..0ce5bfe Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..25a13ff Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..b75126a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..60770c5 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..f6d43e2 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..0ce5bfe Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..25a13ff Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..b75126a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..60770c5 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/snow_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..bde8c8d Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..7be5488 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..fe632f1 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..48bba6e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..a6f696a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..bde8c8d Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..7be5488 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..fe632f1 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..48bba6e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..a6f696a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..bde8c8d Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..7be5488 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..fe632f1 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..48bba6e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..a6f696a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..bde8c8d Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..7be5488 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..fe632f1 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..48bba6e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..a6f696a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..bde8c8d Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..7be5488 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..fe632f1 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..48bba6e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..a6f696a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/sparkles_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..2211643 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..b39c7d3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..4afb46c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..e0f2fda Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..a73977a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..2211643 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..b39c7d3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..4afb46c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..e0f2fda Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..a73977a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..2211643 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..b39c7d3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..4afb46c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..e0f2fda Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..a73977a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..2211643 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..b39c7d3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..4afb46c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..e0f2fda Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..a73977a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..2211643 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..b39c7d3 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..4afb46c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..e0f2fda Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..a73977a Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/vertical_rainbow_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc1.png new file mode 100644 index 0000000..8b93879 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc128.png new file mode 100644 index 0000000..3c47bba Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc255.png new file mode 100644 index 0000000..0f0fd01 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc32.png new file mode 100644 index 0000000..0b45f05 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc64.png new file mode 100644 index 0000000..2e1d037 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp128sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc1.png new file mode 100644 index 0000000..2027c15 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc128.png new file mode 100644 index 0000000..e0f3326 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc255.png new file mode 100644 index 0000000..6ae08de Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc32.png new file mode 100644 index 0000000..cfc3afb Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc64.png new file mode 100644 index 0000000..ba4f30e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp255sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc1.png new file mode 100644 index 0000000..4000222 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc128.png new file mode 100644 index 0000000..b12e335 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc255.png new file mode 100644 index 0000000..f75f494 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc32.png new file mode 100644 index 0000000..5f8261e Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc64.png new file mode 100644 index 0000000..9069a0c Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp32sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc1.png new file mode 100644 index 0000000..d7ed886 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc128.png new file mode 100644 index 0000000..19abadc Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc255.png new file mode 100644 index 0000000..dd7b9d7 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc32.png new file mode 100644 index 0000000..cb12768 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc64.png new file mode 100644 index 0000000..2cf8e62 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp8sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc1.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc1.png new file mode 100644 index 0000000..3d06db0 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc1.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc128.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc128.png new file mode 100644 index 0000000..76cebe9 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc128.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc255.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc255.png new file mode 100644 index 0000000..28606e6 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc255.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc32.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc32.png new file mode 100644 index 0000000..085e0ea Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc32.png differ diff --git a/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc64.png b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc64.png new file mode 100644 index 0000000..dfc3d11 Binary files /dev/null and b/packages/gyver_lamp_effects/test/generators/goldens/zebra_frames_generator.d16sp96sc64.png differ diff --git a/packages/gyver_lamp_effects/test/generators/horizontal_rainbow_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/horizontal_rainbow_frames_generator_test.dart new file mode 100644 index 0000000..8cc2a10 --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/horizontal_rainbow_frames_generator_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('HorizontalRainbowFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(HorizontalRainbowFramesGenerator(dimension: 16).blur, equals(10)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = HorizontalRainbowFramesGenerator(dimension: 16); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = HorizontalRainbowFramesGenerator( + dimension: settings.dimension, + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/horizontal_rainbow_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/lava_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/lava_frames_generator_test.dart new file mode 100644 index 0000000..caef69c --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/lava_frames_generator_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('LavaFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(LavaFramesGenerator(dimension: 16).blur, equals(20)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = LavaFramesGenerator(dimension: 16); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = LavaFramesGenerator(dimension: settings.dimension); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile('goldens/lava_frames_generator.${settings.id}.png'), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/madness_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/madness_frames_generator_test.dart new file mode 100644 index 0000000..7cfca69 --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/madness_frames_generator_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('MadnessFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(MadnessFramesGenerator(dimension: 16).blur, equals(20)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = MadnessFramesGenerator(dimension: 16); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = MadnessFramesGenerator(dimension: settings.dimension); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/madness_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/matrix_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/matrix_frames_generator_test.dart new file mode 100644 index 0000000..fceb1f1 --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/matrix_frames_generator_test.dart @@ -0,0 +1,79 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('MatrixFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(MatrixFramesGenerator(dimension: 16).blur, equals(3)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = MatrixFramesGenerator( + dimension: 16, + random: math.Random(3333), + ); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = MatrixFramesGenerator( + dimension: settings.dimension, + random: math.Random(3333), + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/matrix_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/ocean_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/ocean_frames_generator_test.dart new file mode 100644 index 0000000..8fb9fa2 --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/ocean_frames_generator_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('OceanFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(OceanFramesGenerator(dimension: 16).blur, equals(20)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = OceanFramesGenerator(dimension: 16); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = OceanFramesGenerator(dimension: settings.dimension); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/ocean_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/plasma_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/plasma_frames_generator_test.dart new file mode 100644 index 0000000..b98e21f --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/plasma_frames_generator_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('PlasmaFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(PlasmaFramesGenerator(dimension: 16).blur, equals(20)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = PlasmaFramesGenerator(dimension: 16); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = PlasmaFramesGenerator(dimension: settings.dimension); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/plasma_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/rainbow_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/rainbow_frames_generator_test.dart new file mode 100644 index 0000000..eeb80e6 --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/rainbow_frames_generator_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('RainbowFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(RainbowFramesGenerator(dimension: 16).blur, equals(20)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = RainbowFramesGenerator(dimension: 16); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = RainbowFramesGenerator(dimension: settings.dimension); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/rainbow_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/rainbow_stripes_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/rainbow_stripes_frames_generator_test.dart new file mode 100644 index 0000000..5d9a970 --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/rainbow_stripes_frames_generator_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('RainbowStripesFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(RainbowStripesFramesGenerator(dimension: 16).blur, equals(20)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = RainbowStripesFramesGenerator(dimension: 16); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = RainbowStripesFramesGenerator( + dimension: settings.dimension, + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/rainbow_stripes_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/snow_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/snow_frames_generator_test.dart new file mode 100644 index 0000000..c8c8fef --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/snow_frames_generator_test.dart @@ -0,0 +1,77 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('SnowFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(SnowFramesGenerator(dimension: 16).blur, equals(3)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = SnowFramesGenerator( + dimension: 16, + random: math.Random(3333), + ); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = SnowFramesGenerator( + dimension: settings.dimension, + random: math.Random(3333), + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile('goldens/snow_frames_generator.${settings.id}.png'), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/sparkles_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/sparkles_frames_generator_test.dart new file mode 100644 index 0000000..991d74c --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/sparkles_frames_generator_test.dart @@ -0,0 +1,79 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('SparklesFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(SparklesFramesGenerator(dimension: 16).blur, equals(3)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = SparklesFramesGenerator( + dimension: 16, + random: math.Random(3333), + ); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = SparklesFramesGenerator( + dimension: settings.dimension, + random: math.Random(3333), + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/sparkles_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/vertical_rainbow_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/vertical_rainbow_frames_generator_test.dart new file mode 100644 index 0000000..8d5865a --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/vertical_rainbow_frames_generator_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('VerticalRainbowFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(VerticalRainbowFramesGenerator(dimension: 16).blur, equals(10)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = VerticalRainbowFramesGenerator(dimension: 16); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = VerticalRainbowFramesGenerator( + dimension: settings.dimension, + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/vertical_rainbow_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/generators/zebra_frames_generator_test.dart b/packages/gyver_lamp_effects/test/generators/zebra_frames_generator_test.dart new file mode 100644 index 0000000..b475ce6 --- /dev/null +++ b/packages/gyver_lamp_effects/test/generators/zebra_frames_generator_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; + +import '../helpers/frames_generator_test_sheet.dart'; +import '../helpers/generator_settings_variant.dart'; + +void main() { + group('ZebraFramesGenerator', () { + final settingsVariant = GeneratorSettingsVariant.all(); + + test('has correct blur value', () { + expect(ZebraFramesGenerator(dimension: 16).blur, equals(20)); + }); + + test('reset works correctly', () { + const speed = 8; + const scale = 33; + final generator = ZebraFramesGenerator(dimension: 16); + + final initialFrame = generator.generate(speed: speed, scale: scale); + + generator + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale) + ..generate(speed: speed, scale: scale); + + expect( + generator.generate(speed: speed, scale: scale), + isNot(equals(initialFrame)), + ); + + generator.reset(); + + expect( + generator.generate(speed: speed, scale: scale), + equals(initialFrame), + ); + }); + + testWidgets( + 'renders correctly', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(128, 128); + tester.view.devicePixelRatio = 1.0; + + final settings = settingsVariant.currentValue!; + final generator = ZebraFramesGenerator(dimension: settings.dimension); + + await tester.pumpWidget( + RepaintBoundary( + child: FramesGeneratorTestSheet( + generator: generator, + speed: settings.speed, + scale: settings.scale, + ), + ), + ); + + await expectLater( + find.byType(FramesGeneratorTestSheet), + matchesGoldenFile( + 'goldens/zebra_frames_generator.${settings.id}.png', + ), + ); + }, + variant: settingsVariant, + ); + }); +} diff --git a/packages/gyver_lamp_effects/test/helpers/frames_generator_test_sheet.dart b/packages/gyver_lamp_effects/test/helpers/frames_generator_test_sheet.dart new file mode 100644 index 0000000..7d4617a --- /dev/null +++ b/packages/gyver_lamp_effects/test/helpers/frames_generator_test_sheet.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/widgets/widgets.dart'; + +const _kFramesInRow = 8; + +class FramesGeneratorTestSheet extends StatefulWidget { + const FramesGeneratorTestSheet({ + required this.generator, + required this.speed, + required this.scale, + super.key, + }); + + final FramesGenerator generator; + + final int speed; + + final int scale; + + @override + State createState() => + _FramesGeneratorTestSheetState(); +} + +class _FramesGeneratorTestSheetState extends State { + @override + Widget build(BuildContext context) { + final generator = widget.generator..reset(); + + final dimension = MediaQuery.sizeOf(context).shortestSide / _kFramesInRow; + + return Directionality( + textDirection: TextDirection.ltr, + child: GridView.count( + crossAxisCount: _kFramesInRow, + children: [ + for (var i = 0; i < _kFramesInRow * _kFramesInRow; i++) + SizedBox.square( + dimension: dimension, + child: FramePainter( + frame: generator.generate( + speed: widget.speed, + scale: widget.scale, + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/gyver_lamp_effects/test/helpers/generator_settings_variant.dart b/packages/gyver_lamp_effects/test/helpers/generator_settings_variant.dart new file mode 100644 index 0000000..af193d4 --- /dev/null +++ b/packages/gyver_lamp_effects/test/helpers/generator_settings_variant.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; + +class GeneratorSettings { + const GeneratorSettings({ + required this.dimension, + required this.speed, + required this.scale, + }); + + final int dimension; + + final int speed; + + final int scale; + + @override + String toString() { + return 'Dimension: $dimension, Speed: $speed, Scale $scale'; + } + + String get id => 'd${dimension}sp${speed}sc$scale'; +} + +class GeneratorSettingsVariant extends ValueVariant { + GeneratorSettingsVariant(super.values); + + GeneratorSettingsVariant.all() + : super( + const { + GeneratorSettings(dimension: 16, speed: 8, scale: 1), + GeneratorSettings(dimension: 16, speed: 8, scale: 32), + GeneratorSettings(dimension: 16, speed: 8, scale: 64), + GeneratorSettings(dimension: 16, speed: 8, scale: 128), + GeneratorSettings(dimension: 16, speed: 8, scale: 255), + GeneratorSettings(dimension: 16, speed: 32, scale: 1), + GeneratorSettings(dimension: 16, speed: 32, scale: 32), + GeneratorSettings(dimension: 16, speed: 32, scale: 64), + GeneratorSettings(dimension: 16, speed: 32, scale: 128), + GeneratorSettings(dimension: 16, speed: 32, scale: 255), + GeneratorSettings(dimension: 16, speed: 96, scale: 1), + GeneratorSettings(dimension: 16, speed: 96, scale: 32), + GeneratorSettings(dimension: 16, speed: 96, scale: 64), + GeneratorSettings(dimension: 16, speed: 96, scale: 128), + GeneratorSettings(dimension: 16, speed: 96, scale: 255), + GeneratorSettings(dimension: 16, speed: 128, scale: 1), + GeneratorSettings(dimension: 16, speed: 128, scale: 32), + GeneratorSettings(dimension: 16, speed: 128, scale: 64), + GeneratorSettings(dimension: 16, speed: 128, scale: 128), + GeneratorSettings(dimension: 16, speed: 128, scale: 255), + GeneratorSettings(dimension: 16, speed: 255, scale: 1), + GeneratorSettings(dimension: 16, speed: 255, scale: 32), + GeneratorSettings(dimension: 16, speed: 255, scale: 64), + GeneratorSettings(dimension: 16, speed: 255, scale: 128), + GeneratorSettings(dimension: 16, speed: 255, scale: 255), + }, + ); + + @override + String describeValue(GeneratorSettings value) => value.toString(); +} diff --git a/packages/gyver_lamp_effects/test/models/frame_test.dart b/packages/gyver_lamp_effects/test/models/frame_test.dart new file mode 100644 index 0000000..44db3a8 --- /dev/null +++ b/packages/gyver_lamp_effects/test/models/frame_test.dart @@ -0,0 +1,80 @@ +// ignore_for_file: prefer_const_literals_to_create_immutables + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/models/frame.dart'; + +void main() { + group('Frame', () { + test('can be instantiated', () { + expect( + Frame( + dimension: 1, + data: [Colors.black], + ), + isNotNull, + ); + }); + + test('throws AssertionError when dimension is lower that 1', () { + expect( + () => Frame( + dimension: 0, + data: [Colors.black], + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals('dimension must be greater that 0'), + ), + ), + ); + }); + + test('throws AssertionError when data length is not valid', () { + expect( + () => Frame( + dimension: 2, + data: [Colors.black], + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals('data length must be equal to dimension^2'), + ), + ), + ); + }); + + test('supports equality', () { + expect( + Frame(dimension: 1, data: [Colors.black]), + equals(Frame(dimension: 1, data: [Colors.black])), + ); + + expect( + Frame(dimension: 1, data: [Colors.black]), + isNot(equals(Frame(dimension: 1, data: [Colors.white]))), + ); + + expect( + Frame(dimension: 1, data: [Colors.black]), + isNot( + equals( + Frame( + dimension: 2, + data: [ + Colors.black, + Colors.white, + Colors.black, + Colors.white, + ], + ), + ), + ), + ); + }); + }); +} diff --git a/packages/gyver_lamp_effects/test/widgets/fidgeter_test.dart b/packages/gyver_lamp_effects/test/widgets/fidgeter_test.dart new file mode 100644 index 0000000..d16bf5b --- /dev/null +++ b/packages/gyver_lamp_effects/test/widgets/fidgeter_test.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/widgets/widgets.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + Matrix4 calculateTransformation(Offset offset) { + return Matrix4.identity() + ..setEntry(3, 2, 0.005) + ..multiply(Matrix4.rotationX(offset.dy * kMaxRotation)) + ..multiply(Matrix4.rotationY(-offset.dx * kMaxRotation)); + } + + group('Fidgeter', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject( + const Fidgeter( + foregroundChild: Icon(GyverLampIcons.done), + child: SizedBox.square( + dimension: 200, + child: ColoredBox( + color: Colors.red, + ), + ), + ), + ); + + expect(find.byType(Fidgeter), findsOneWidget); + expect(find.byType(ColoredBox), findsOneWidget); + expect(find.byIcon(GyverLampIcons.done), findsOneWidget); + }); + + testWidgets( + 'transforms child according to the touches and drags', + (tester) async { + await tester.pumpSubject( + const Fidgeter( + child: ColoredBox( + color: Colors.red, + ), + ), + ); + + expect( + tester.fidgeterTransformation, + equals(calculateTransformation(Offset.zero)), + ); + + final fidgeter = find.byType(Fidgeter); + + final center = tester.getCenter(fidgeter); + final topLeft = tester.getTopLeft(fidgeter); + final topRight = tester.getTopRight(fidgeter); + final bottomLeft = tester.getBottomLeft(fidgeter); + final bottomRight = tester.getBottomRight(fidgeter); + + final gesture = await tester.createGesture(); + + await gesture.down(center); + await tester.pumpAndSettle(); + await gesture.moveTo(topLeft); + await tester.pumpAndSettle(); + + expect( + tester.fidgeterTransformation, + equals(calculateTransformation(const Offset(-1, -1))), + ); + + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await gesture.moveTo(topRight); + await tester.pumpAndSettle(); + + expect( + tester.fidgeterTransformation, + equals(calculateTransformation(const Offset(1, -1))), + ); + + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await gesture.moveTo(bottomLeft); + await tester.pumpAndSettle(); + + expect( + tester.fidgeterTransformation, + equals(calculateTransformation(const Offset(-1, 1))), + ); + + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await gesture.moveTo(bottomRight); + await tester.pumpAndSettle(); + + expect( + tester.fidgeterTransformation, + equals(calculateTransformation(const Offset(1, 1))), + ); + + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect( + tester.fidgeterTransformation, + equals(calculateTransformation(Offset.zero)), + ); + }, + ); + + testWidgets('transforms child back after tap up', (tester) async { + await tester.pumpSubject( + const Fidgeter( + child: ColoredBox( + color: Colors.red, + ), + ), + ); + + expect( + tester.fidgeterTransformation, + equals(calculateTransformation(Offset.zero)), + ); + + final topLeft = tester.getTopLeft(find.byType(Fidgeter)); + + final gesture = await tester.startGesture(topLeft); + await tester.pumpAndSettle(); + + expect( + tester.fidgeterTransformation, + equals(calculateTransformation(const Offset(-1, -1))), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect( + tester.fidgeterTransformation, + equals(calculateTransformation(Offset.zero)), + ); + }); + }); +} + +extension _Fidgeter on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: SizedBox.square( + dimension: 200, + child: child, + ), + ), + ), + ), + ); + } + + Matrix4 get fidgeterTransformation { + return widget( + find.descendant( + of: find.byType(Fidgeter), + matching: find.byType(Transform), + ), + ).transform; + } +} diff --git a/packages/gyver_lamp_effects/test/widgets/frame_painter_test.dart b/packages/gyver_lamp_effects/test/widgets/frame_painter_test.dart new file mode 100644 index 0000000..aa4fdf5 --- /dev/null +++ b/packages/gyver_lamp_effects/test/widgets/frame_painter_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/models/frame.dart'; +import 'package:gyver_lamp_effects/src/widgets/widgets.dart'; + +void main() { + group('FramePainter', () { + testWidgets( + 'renders correctly when width equals height and dimension is its factor', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(30, 30); + tester.view.devicePixelRatio = 1.0; + + final frame = Frame( + dimension: 2, + data: const [ + Colors.red, + Colors.green, + Colors.blue, + Colors.black, + ], + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramePainter(frame: frame), + ), + ); + + await expectLater( + find.byType(FramePainter), + matchesGoldenFile('goldens/frame_painter.0.png'), + ); + }, + ); + + testWidgets( + 'renders correctly when width equals height and dimension is not its ' + 'factor', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(35, 35); + tester.view.devicePixelRatio = 1.0; + + final frame = Frame( + dimension: 2, + data: const [ + Colors.red, + Colors.green, + Colors.blue, + Colors.black, + ], + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramePainter(frame: frame), + ), + ); + + await expectLater( + find.byType(FramePainter), + matchesGoldenFile('goldens/frame_painter.1.png'), + ); + }, + ); + + testWidgets( + 'renders correctly when width is greater than height', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(60, 30); + tester.view.devicePixelRatio = 1.0; + + final frame = Frame( + dimension: 2, + data: const [ + Colors.red, + Colors.green, + Colors.blue, + Colors.black, + ], + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramePainter(frame: frame), + ), + ); + + await expectLater( + find.byType(FramePainter), + matchesGoldenFile('goldens/frame_painter.2.png'), + ); + }, + ); + + testWidgets( + 'renders correctly when width is less than height', + (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(30, 60); + tester.view.devicePixelRatio = 1.0; + + final frame = Frame( + dimension: 2, + data: const [ + Colors.red, + Colors.green, + Colors.blue, + Colors.black, + ], + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramePainter(frame: frame), + ), + ); + + await expectLater( + find.byType(FramePainter), + matchesGoldenFile('goldens/frame_painter.3.png'), + ); + }, + ); + + testWidgets('repaints when frame changes', (tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(30, 30); + tester.view.devicePixelRatio = 1.0; + + final frame1 = Frame( + dimension: 2, + data: const [ + Colors.red, + Colors.green, + Colors.blue, + Colors.black, + ], + ); + + final frame2 = Frame( + dimension: 2, + data: const [ + Colors.black, + Colors.blue, + Colors.green, + Colors.red, + ], + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramePainter(frame: frame1), + ), + ); + + await tester.pumpWidget( + RepaintBoundary( + child: FramePainter(frame: frame2), + ), + ); + + await expectLater( + find.byType(FramePainter), + matchesGoldenFile('goldens/frame_painter.4.png'), + ); + }); + }); +} diff --git a/packages/gyver_lamp_effects/test/widgets/frames_builder_test.dart b/packages/gyver_lamp_effects/test/widgets/frames_builder_test.dart new file mode 100644 index 0000000..272c8e9 --- /dev/null +++ b/packages/gyver_lamp_effects/test/widgets/frames_builder_test.dart @@ -0,0 +1,614 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; +import 'package:gyver_lamp_effects/src/widgets/widgets.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockFramesGenerator extends Mock implements FramesGenerator {} + +void main() { + group('FramesBuilder', () { + const dimension = 9; + const frameSize = dimension * dimension; + + final redFrame = Frame( + dimension: dimension, + data: List.filled(frameSize, Colors.red), + ); + final blueFrame = Frame( + dimension: dimension, + data: List.filled(frameSize, Colors.blue), + ); + final greenFrame = Frame( + dimension: dimension, + data: List.filled(frameSize, Colors.green), + ); + final whiteFrame = Frame( + dimension: dimension, + data: List.filled(frameSize, Colors.white), + ); + + late FramesGenerator generator; + + setUp(() { + generator = _MockFramesGenerator(); + when(() => generator.dimension).thenReturn(dimension); + when(() => generator.blur).thenReturn(10); + }); + + testWidgets('renders correctly', (tester) async { + when( + () => generator.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(whiteFrame); + when(generator.reset).thenAnswer((_) async {}); + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: true, + speed: 16, + scale: 30, + ), + ); + + expect(find.byType(FramesBuilder), findsOneWidget); + expect(find.byType(FramePainter), findsOneWidget); + + final painter = tester.widget(find.byType(FramePainter)); + expect(painter.frame, equals(whiteFrame)); + }); + + testWidgets('resets generator when is paused', (tester) async { + when( + () => generator.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(whiteFrame); + when(generator.reset).thenAnswer((_) async {}); + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: true, + speed: 16, + scale: 30, + ), + ); + + verify(() => generator.reset()).called(1); + }); + + testWidgets('does not reset generator when is not paused', (tester) async { + when( + () => generator.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(whiteFrame); + when(generator.reset).thenAnswer((_) async {}); + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: false, + speed: 16, + scale: 30, + ), + ); + + verifyNever(() => generator.reset()); + }); + + testWidgets('does not generate next frame if paused', (tester) async { + when( + () => generator.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(whiteFrame); + when(generator.reset).thenAnswer((_) async {}); + + const speed = 16; + const scale = 30; + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: true, + speed: speed, + scale: scale, + ), + ); + + verify(() => generator.generate(speed: speed, scale: scale)).called(1); + + await tester.pump(const Duration(seconds: 5)); + + verifyNever(() => generator.generate(speed: speed, scale: scale)); + }); + + testWidgets('generates new frame according to the speed', (tester) async { + final frames = [redFrame, greenFrame, blueFrame]; + var index = 0; + + when( + () => generator.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenAnswer((_) => frames[index++]); + + const speed = 16; + const scale = 30; + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: false, + speed: speed, + scale: scale, + ), + ); + + verify(() => generator.generate(speed: speed, scale: scale)).called(1); + expect( + tester.widget(find.byType(FramePainter)).frame, + equals(redFrame), + ); + + await tester.pump(const Duration(milliseconds: speed ~/ 2)); + + verifyNever(() => generator.generate(speed: speed, scale: scale)); + + await tester.pump(const Duration(milliseconds: speed ~/ 2)); + + verify(() => generator.generate(speed: speed, scale: scale)).called(1); + expect( + tester.widget(find.byType(FramePainter)).frame, + equals(greenFrame), + ); + + await tester.pump(const Duration(milliseconds: speed)); + + verify(() => generator.generate(speed: speed, scale: scale)).called(1); + expect( + tester.widget(find.byType(FramePainter)).frame, + equals(blueFrame), + ); + }); + + testWidgets('does not generate new frames after pausing', (tester) async { + when( + () => generator.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(whiteFrame); + + const speed = 16; + const scale = 30; + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: false, + speed: speed, + scale: scale, + ), + ); + + verify(() => generator.generate(speed: speed, scale: scale)).called(1); + + await tester.pump(const Duration(milliseconds: speed)); + + verify(() => generator.generate(speed: speed, scale: scale)).called(1); + + await tester.pump(const Duration(milliseconds: speed ~/ 2)); + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: true, + speed: speed, + scale: scale, + ), + ); + + await tester.pump(const Duration(seconds: 5)); + + verifyNever(() => generator.generate(speed: speed, scale: scale)); + }); + + testWidgets('does not generate new frames after disposal', (tester) async { + when( + () => generator.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(whiteFrame); + + const speed = 16; + const scale = 30; + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: false, + speed: speed, + scale: scale, + ), + ); + + verify(() => generator.generate(speed: speed, scale: scale)).called(1); + + await tester.pump(const Duration(milliseconds: speed)); + + verify(() => generator.generate(speed: speed, scale: scale)).called(1); + + await tester.pump(const Duration(milliseconds: speed ~/ 2)); + + await tester.pumpSubject(const Center()); + + await tester.pump(const Duration(seconds: 5)); + + verifyNever(() => generator.generate(speed: speed, scale: scale)); + }); + + testWidgets('generates new frames after unpausing', (tester) async { + when( + () => generator.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(whiteFrame); + when(generator.reset).thenAnswer((_) async {}); + + const speed = 16; + const scale = 30; + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: true, + speed: speed, + scale: scale, + ), + ); + + verify(() => generator.generate(speed: speed, scale: scale)).called(1); + + await tester.pump(const Duration(milliseconds: speed)); + + verifyNever(() => generator.generate(speed: speed, scale: scale)); + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: false, + speed: speed, + scale: scale, + ), + ); + + await tester.pump(const Duration(milliseconds: speed)); + + verify(() => generator.generate(speed: speed, scale: scale)).called(1); + }); + + testWidgets( + 'generates new frame immediately after generator swap when paused', + (tester) async { + final generator1 = _MockFramesGenerator(); + when( + () => generator1.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(redFrame); + when(generator1.reset).thenAnswer((_) async {}); + + final generator2 = _MockFramesGenerator(); + when( + () => generator2.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(blueFrame); + when(generator2.reset).thenAnswer((_) async {}); + + const speed = 16; + const scale = 30; + + await tester.pumpSubject( + FramesBuilder( + generator: generator1, + paused: true, + speed: speed, + scale: scale, + ), + ); + + verify(() => generator1.generate(speed: speed, scale: scale)).called(1); + expect( + tester.widget(find.byType(FramePainter)).frame, + equals(redFrame), + ); + + await tester.pumpSubject( + FramesBuilder( + generator: generator2, + paused: true, + speed: speed, + scale: scale, + ), + ); + + verifyNever(() => generator1.generate(speed: speed, scale: scale)); + verify(() => generator2.generate(speed: speed, scale: scale)).called(1); + expect( + tester.widget(find.byType(FramePainter)).frame, + equals(blueFrame), + ); + }, + ); + + testWidgets( + 'does not generate new frame immediately after generator swap ' + 'when is not paused', + (tester) async { + final generator1 = _MockFramesGenerator(); + when( + () => generator1.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(redFrame); + + final generator2 = _MockFramesGenerator(); + when( + () => generator2.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(blueFrame); + + const speed = 16; + const scale = 30; + + await tester.pumpSubject( + FramesBuilder( + generator: generator1, + paused: false, + speed: speed, + scale: scale, + ), + ); + + verify(() => generator1.generate(speed: speed, scale: scale)).called(1); + expect( + tester.widget(find.byType(FramePainter)).frame, + equals(redFrame), + ); + + await tester.pumpSubject( + FramesBuilder( + generator: generator2, + paused: false, + speed: speed, + scale: scale, + ), + ); + + verifyNever(() => generator1.generate(speed: speed, scale: scale)); + verifyNever(() => generator2.generate(speed: speed, scale: scale)); + + await tester.pump(const Duration(milliseconds: speed)); + + verifyNever(() => generator1.generate(speed: speed, scale: scale)); + verify(() => generator2.generate(speed: speed, scale: scale)).called(1); + expect( + tester.widget(find.byType(FramePainter)).frame, + equals(blueFrame), + ); + }, + ); + + testWidgets( + 'generates new frame immediately after scale change when paused', + (tester) async { + when( + () => generator.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(whiteFrame); + when(generator.reset).thenAnswer((_) async {}); + + const speed = 16; + const scale1 = 30; + const scale2 = 60; + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: true, + speed: speed, + scale: scale1, + ), + ); + + verify(() => generator.generate(speed: speed, scale: scale1)).called(1); + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: true, + speed: speed, + scale: scale2, + ), + ); + + verifyNever(() => generator.generate(speed: speed, scale: scale1)); + verify(() => generator.generate(speed: speed, scale: scale2)).called(1); + }, + ); + + testWidgets( + 'does not generate new frame immediately after scale change ' + 'when is not paused', + (tester) async { + when( + () => generator.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(whiteFrame); + when(generator.reset).thenAnswer((_) async {}); + + const speed = 16; + const scale1 = 30; + const scale2 = 60; + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: false, + speed: speed, + scale: scale1, + ), + ); + + verify(() => generator.generate(speed: speed, scale: scale1)).called(1); + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: false, + speed: speed, + scale: scale2, + ), + ); + + verifyNever(() => generator.generate(speed: speed, scale: scale1)); + verifyNever(() => generator.generate(speed: speed, scale: scale2)); + + await tester.pump(const Duration(milliseconds: speed)); + + verifyNever(() => generator.generate(speed: speed, scale: scale1)); + verify(() => generator.generate(speed: speed, scale: scale2)).called(1); + }, + ); + + testWidgets( + 'does not generate new frame after speed change when paused', + (tester) async { + when( + () => generator.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(whiteFrame); + when(generator.reset).thenAnswer((_) async {}); + + const speed1 = 16; + const speed2 = 32; + const scale = 30; + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: true, + speed: speed1, + scale: scale, + ), + ); + + verify(() => generator.generate(speed: speed1, scale: scale)).called(1); + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: true, + speed: speed2, + scale: scale, + ), + ); + + await tester.pumpAndSettle(const Duration(seconds: 5)); + + verifyNever(() => generator.generate(speed: speed1, scale: scale)); + verifyNever(() => generator.generate(speed: speed2, scale: scale)); + }, + ); + + testWidgets( + 'updates internal timer correctly after speed change', + (tester) async { + when( + () => generator.generate( + speed: any(named: 'speed'), + scale: any(named: 'scale'), + ), + ).thenReturn(whiteFrame); + + const speed1 = 16; + const speed2 = speed1 * 2; + const scale = 30; + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: false, + speed: speed1, + scale: scale, + ), + ); + + verify(() => generator.generate(speed: speed1, scale: scale)).called(1); + + await tester.pumpSubject( + FramesBuilder( + generator: generator, + paused: false, + speed: speed2, + scale: scale, + ), + ); + + await tester.pump(const Duration(milliseconds: speed1)); + + verifyNever(() => generator.generate(speed: speed1, scale: scale)); + verifyNever(() => generator.generate(speed: speed2, scale: scale)); + + await tester.pump(const Duration(milliseconds: speed1)); + + verifyNever(() => generator.generate(speed: speed1, scale: scale)); + verify(() => generator.generate(speed: speed2, scale: scale)).called(1); + }, + ); + }); +} + +extension _FramesBuilder on WidgetTester { + Future pumpSubject(Widget child) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: SizedBox.square( + dimension: 200, + child: child, + ), + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.0.png b/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.0.png new file mode 100644 index 0000000..b216e82 Binary files /dev/null and b/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.0.png differ diff --git a/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.1.png b/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.1.png new file mode 100644 index 0000000..bb5114e Binary files /dev/null and b/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.1.png differ diff --git a/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.2.png b/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.2.png new file mode 100644 index 0000000..8506385 Binary files /dev/null and b/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.2.png differ diff --git a/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.3.png b/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.3.png new file mode 100644 index 0000000..87b8ec5 Binary files /dev/null and b/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.3.png differ diff --git a/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.4.png b/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.4.png new file mode 100644 index 0000000..285ac8e Binary files /dev/null and b/packages/gyver_lamp_effects/test/widgets/goldens/frame_painter.4.png differ diff --git a/packages/gyver_lamp_effects/test/widgets/gyver_lamp_effect_test.dart b/packages/gyver_lamp_effects/test/widgets/gyver_lamp_effect_test.dart new file mode 100644 index 0000000..02adf97 --- /dev/null +++ b/packages/gyver_lamp_effects/test/widgets/gyver_lamp_effect_test.dart @@ -0,0 +1,460 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_effects/src/generators/generators.dart'; +import 'package:gyver_lamp_effects/src/models/models.dart'; +import 'package:gyver_lamp_effects/src/widgets/widgets.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('GyverLampEffect', () { + const speed = 16; + const scale = 30; + + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.clouds, + speed: speed, + scale: scale, + ), + ); + + expect(find.byType(GyverLampEffect), findsOneWidget); + expect(find.byType(Fidgeter), findsOneWidget); + expect(find.byType(FramesBuilder), findsOneWidget); + expect( + find.ancestor( + of: find.byIcon(GyverLampIcons.play), + matching: find.byType(FlatIconButton), + ), + findsOneWidget, + ); + }); + + testWidgets('passes correct values to the FramesBuilder', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.clouds, + speed: 1, + scale: 2, + dimension: 3, + ), + ); + + final builder = tester.widget(find.byType(FramesBuilder)); + + expect( + builder.generator, + isA().having( + (g) => g.dimension, + 'dimension', + equals(3), + ), + ); + expect(builder.speed, equals(1)); + expect(builder.scale, equals(2)); + expect(builder.paused, isTrue); + }); + + group('uses correct generator', () { + testWidgets('when type is GyverLampEffectType.sparkles', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.sparkles, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets('when type is GyverLampEffectType.fire', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.fire, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets( + 'when type is GyverLampEffectType.verticalRainbow', + (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.verticalRainbow, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }, + ); + + testWidgets( + 'when type is GyverLampEffectType.horizontalRainbow', + (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.horizontalRainbow, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }, + ); + + testWidgets('when type is GyverLampEffectType.colors', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.colors, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets('when type is GyverLampEffectType.madness', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.madness, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets('when type is GyverLampEffectType.clouds', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.clouds, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets('when type is GyverLampEffectType.lava', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.lava, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets('when type is GyverLampEffectType.plasma', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.plasma, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets('when type is GyverLampEffectType.rainbow', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.rainbow, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets( + 'when type is GyverLampEffectType.rainbowStripes', + (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.rainbowStripes, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }, + ); + + testWidgets('when type is GyverLampEffectType.zebra', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.zebra, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets('when type is GyverLampEffectType.forest', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.forest, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets('when type is GyverLampEffectType.ocean', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.ocean, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets('when type is GyverLampEffectType.color', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.color, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets('when type is GyverLampEffectType.snow', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.snow, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets('when type is GyverLampEffectType.matrix', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.matrix, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets('when type is GyverLampEffectType.fireflies', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.fireflies, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + }); + + testWidgets('renders correct effect after type change', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.clouds, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.fire, + speed: speed, + scale: scale, + ), + ); + + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byType(FramesBuilder)).generator, + isA(), + ); + }); + + testWidgets('can be paused and continued', (tester) async { + await tester.pumpSubject( + const GyverLampEffect( + type: GyverLampEffectType.clouds, + speed: speed, + scale: scale, + ), + ); + + expect( + tester.widget(find.byType(FramesBuilder)).paused, + isTrue, + ); + + await tester.tap( + find.ancestor( + of: find.byIcon(GyverLampIcons.play), + matching: find.byType(FlatIconButton), + ), + ); + + await tester.pump(); + + expect( + find.ancestor( + of: find.byIcon(GyverLampIcons.play), + matching: find.byType(FlatIconButton), + ), + findsNothing, + ); + expect( + find.ancestor( + of: find.byIcon(GyverLampIcons.pause), + matching: find.byType(FlatIconButton), + ), + findsOneWidget, + ); + expect( + tester.widget(find.byType(FramesBuilder)).paused, + isFalse, + ); + + await tester.tap( + find.ancestor( + of: find.byIcon(GyverLampIcons.pause), + matching: find.byType(FlatIconButton), + ), + ); + + await tester.pump(); + + expect( + find.ancestor( + of: find.byIcon(GyverLampIcons.pause), + matching: find.byType(FlatIconButton), + ), + findsNothing, + ); + expect( + find.ancestor( + of: find.byIcon(GyverLampIcons.play), + matching: find.byType(FlatIconButton), + ), + findsOneWidget, + ); + expect( + tester.widget(find.byType(FramesBuilder)).paused, + isTrue, + ); + }); + }); +} + +extension _GyverLampEffect on WidgetTester { + Future pumpSubject(Widget child) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: SizedBox.square( + dimension: 200, + child: child, + ), + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_icons/.gitignore b/packages/gyver_lamp_icons/.gitignore new file mode 100644 index 0000000..4fe5c69 --- /dev/null +++ b/packages/gyver_lamp_icons/.gitignore @@ -0,0 +1,15 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock + +# 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/ + +# Coverage +coverage/ diff --git a/packages/gyver_lamp_icons/CHANGELOG.md b/packages/gyver_lamp_icons/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/gyver_lamp_icons/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/gyver_lamp_icons/README.md b/packages/gyver_lamp_icons/README.md new file mode 100644 index 0000000..5d7e2b4 --- /dev/null +++ b/packages/gyver_lamp_icons/README.md @@ -0,0 +1,13 @@ +# Gyver Lamp Icons + +[![coverage][coverage_badge]][coverage_badge] +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +Icons set for Gyver Lamp. + +[coverage_badge]: https://raw.githubusercontent.com/VeryGoodOpenSource/very_good_cli/main/coverage_badge.svg +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT \ No newline at end of file diff --git a/packages/gyver_lamp_icons/analysis_options.yaml b/packages/gyver_lamp_icons/analysis_options.yaml new file mode 100644 index 0000000..00c7061 --- /dev/null +++ b/packages/gyver_lamp_icons/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + exclude: [resources/**] \ No newline at end of file diff --git a/packages/gyver_lamp_icons/assets/fonts/GyverLampIcons.ttf b/packages/gyver_lamp_icons/assets/fonts/GyverLampIcons.ttf new file mode 100644 index 0000000..1b97870 Binary files /dev/null and b/packages/gyver_lamp_icons/assets/fonts/GyverLampIcons.ttf differ diff --git a/packages/gyver_lamp_icons/coverage_badge.svg b/packages/gyver_lamp_icons/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/packages/gyver_lamp_icons/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/gyver_lamp_icons/lib/gyver_lamp_icons.dart b/packages/gyver_lamp_icons/lib/gyver_lamp_icons.dart new file mode 100644 index 0000000..e8ae012 --- /dev/null +++ b/packages/gyver_lamp_icons/lib/gyver_lamp_icons.dart @@ -0,0 +1,4 @@ +/// Icons set for the Gyver Lamp. +library; + +export 'src/gyver_lamp_icons.dart'; diff --git a/packages/gyver_lamp_icons/lib/src/gyver_lamp_icons.dart b/packages/gyver_lamp_icons/lib/src/gyver_lamp_icons.dart new file mode 100644 index 0000000..f36ca95 --- /dev/null +++ b/packages/gyver_lamp_icons/lib/src/gyver_lamp_icons.dart @@ -0,0 +1,121 @@ +// ignore_for_file: constant_identifier_names + +import 'package:flutter/widgets.dart'; + +/// {@template gyver_lamp_icons} +/// Icons set for Gyver Lamp. +/// {@endtemplate} +abstract class GyverLampIcons { + static const _fontFamily = 'GyverLampIcons'; + + static const String _fontPackage = 'gyver_lamp_icons'; + + /// Icons font family. + @visibleForTesting + static String get fontFamily => _fontFamily; + + /// Icons font package. + @visibleForTesting + static String get fontPackage => _fontPackage; + + /// Done icon. + static const IconData done = + IconData(0xe800, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Arrow Left icon. + static const IconData arrow_left = + IconData(0xe801, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Arrow Outward icon. + static const IconData arrow_outward = + IconData(0xe802, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Arrow Right icon. + static const IconData arrow_right = + IconData(0xe803, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Chevron Down icon. + static const IconData chevron_down = + IconData(0xe804, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Chevron Up icon. + static const IconData chevron_up = + IconData(0xe805, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Close icon. + static const IconData close = + IconData(0xe806, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// GitHub icon. + static const IconData github = + IconData(0xe807, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Group icon. + static const IconData group = + IconData(0xe808, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Language icon. + static const IconData language = + IconData(0xe809, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Mail icon. + static const IconData mail = + IconData(0xe80a, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Moon icon. + static const IconData moon = + IconData(0xe80b, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Policy icon. + static const IconData policy = + IconData(0xe80c, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Scale icon. + static const IconData scale = + IconData(0xe80d, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Settings icon. + static const IconData settings = + IconData(0xe80e, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Speed icon. + static const IconData speed = + IconData(0xe80f, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Sun icon. + static const IconData sun = + IconData(0xe810, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Twitter icon. + + static const IconData twitter = + IconData(0xe811, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Warning icon. + static const IconData warning = + IconData(0xe812, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// WIFI icon. + static const IconData wifi = + IconData(0xe813, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Align left icon. + static const IconData align_left = + IconData(0xe814, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Pause icon. + static const IconData pause = + IconData(0xe815, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Play icon. + static const IconData play = + IconData(0xe816, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// X icon. + static const IconData x = + IconData(0xe817, fontFamily: _fontFamily, fontPackage: _fontPackage); + + /// Dribbble icon. + static const IconData dribbble = + IconData(0xe818, fontFamily: _fontFamily, fontPackage: _fontPackage); +} diff --git a/packages/gyver_lamp_icons/pubspec.yaml b/packages/gyver_lamp_icons/pubspec.yaml new file mode 100644 index 0000000..1c12a52 --- /dev/null +++ b/packages/gyver_lamp_icons/pubspec.yaml @@ -0,0 +1,23 @@ +name: gyver_lamp_icons +description: Icons set for Gyver Lamp. +version: 1.0.0 +publish_to: none + +environment: + sdk: '>=3.0.1 <4.0.0' + flutter: ">=3.10.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: 5.1.0 + +flutter: + fonts: + - family: GyverLampIcons + fonts: + - asset: assets/fonts/GyverLampIcons.ttf diff --git a/packages/gyver_lamp_icons/resources/fluttericon/config.json b/packages/gyver_lamp_icons/resources/fluttericon/config.json new file mode 100644 index 0000000..98ccf53 --- /dev/null +++ b/packages/gyver_lamp_icons/resources/fluttericon/config.json @@ -0,0 +1,360 @@ +{ + "name": "GyverLampIcons", + "css_prefix_text": "", + "css_use_suffix": false, + "hinting": true, + "units_per_em": 1000, + "ascent": 850, + "glyphs": [ + { + "uid": "5d55d7ecdf62d4eacf23a18e1840f610", + "css": "arrow_left", + "code": 59393, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M465.6 811.5L176 521.9A31.8 31.8 0 0 1 168.8 511.5 30.6 30.6 0 0 1 166.7 500C166.7 495.8 167.4 492 168.7 488.5A31.8 31.8 0 0 1 176 478.1L466.7 187.5C472.2 182 479.2 179.2 487.5 179.2S503.1 182.3 509.4 188.5A30 30 0 0 1 518.7 210.4 30 30 0 0 1 509.4 232.3L272.9 468.8H789.6A30.3 30.3 0 0 1 820.8 500 30.3 30.3 0 0 1 789.6 531.3H272.9L510.4 768.7C516 774.3 518.7 781.3 518.7 789.6A30 30 0 0 1 509.4 811.5 30 30 0 0 1 487.5 820.8 30 30 0 0 1 465.6 811.5Z", + "width": 1000 + }, + "search": [ + "arrow_left" + ] + }, + { + "uid": "2c317f367a881ee46e2b717e996e9cdc", + "css": "arrow_outward", + "code": 59394, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M706.1 339.2L284.2 761.1A31.1 31.1 0 0 1 238.9 761.1 31.8 31.8 0 0 1 229.2 737.9C229.2 728.9 232.4 721.2 238.9 714.7L660.8 293.9H285.3A31.3 31.3 0 0 1 262.2 284.5 31.6 31.6 0 0 1 252.9 261.3 31.5 31.5 0 0 1 285.3 229.2H738.5C747.6 229.2 755.3 232.2 761.5 238.5A31.3 31.3 0 0 1 770.8 261.5V714.7A31.3 31.3 0 0 1 761.5 737.8 31.6 31.6 0 0 1 738.3 747.1 31.6 31.6 0 0 1 706.1 714.7V339.2Z", + "width": 1000 + }, + "search": [ + "arrow_outward" + ] + }, + { + "uid": "e43186479360aa24987fabb6734c9433", + "css": "arrow_right", + "code": 59395, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M484.1 196.2A28.3 28.3 0 0 0 475.6 217.6 28.3 28.3 0 0 0 484.1 239L724 469.2H198.5A31.7 31.7 0 0 0 175.7 477.9 29 29 0 0 0 166.7 499.7C166.7 508.6 169.7 515.9 175.7 521.7A31.7 31.7 0 0 0 198.5 530.3H724L484.1 760.5A29 29 0 0 0 475.6 782.5C475.6 791.6 478.4 798.9 484.1 804.3A30.8 30.8 0 0 0 506.4 812.5C515.5 812.5 523 809.8 528.7 804.3L823.8 521.2A31 31 0 0 0 831.2 511 28.8 28.8 0 0 0 831.2 489.1 29.5 29.5 0 0 0 823.8 478.4L528.7 195.2C523 189.8 515.6 187.2 506.4 187.5A32.7 32.7 0 0 0 484.1 196.2Z", + "width": 1000 + }, + "search": [ + "arrow_right" + ] + }, + { + "uid": "f7629259e873d4a4ab3999ca7a6ab61e", + "css": "chevron_down", + "code": 59396, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M338.5 375L500.2 536.7 661.9 375A41.5 41.5 0 1 1 720.6 433.8L529.4 625A41.5 41.5 0 0 1 470.6 625L279.4 433.8A41.5 41.5 0 0 1 279.4 375C295.6 359.2 322.3 358.7 338.5 375Z", + "width": 1000 + }, + "search": [ + "chevron_down" + ] + }, + { + "uid": "325c269372a2c03113d3d4f0f4a74e0c", + "css": "chevron_up", + "code": 59397, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M338.5 625L500.2 463.3 661.9 625A41.5 41.5 0 1 0 720.6 566.3L529.4 375A41.5 41.5 0 0 0 470.6 375L279.4 566.3A41.5 41.5 0 0 0 279.4 625C295.6 640.8 322.3 641.3 338.5 625Z", + "width": 1000 + }, + "search": [ + "chevron_up" + ] + }, + { + "uid": "766de14f968754a3106006c39ead2d90", + "css": "close", + "code": 59398, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500 543.8L281.3 762.5A30 30 0 0 1 259.4 771.9 30 30 0 0 1 237.5 762.5 30 30 0 0 1 228.1 740.6C228.1 732.3 231.2 725 237.5 718.8L456.2 500 237.5 281.3A30 30 0 0 1 228.1 259.4C228.1 251 231.3 243.7 237.5 237.5A30 30 0 0 1 259.4 228.1C267.7 228.1 275 231.2 281.3 237.5L500 456.2 718.8 237.5A30 30 0 0 1 740.6 228.1C749 228.1 756.2 231.2 762.5 237.5A30 30 0 0 1 771.9 259.4 30 30 0 0 1 762.5 281.3L543.8 500 762.5 718.8A30 30 0 0 1 771.9 740.6 30 30 0 0 1 762.5 762.5 30 30 0 0 1 740.6 771.9 30 30 0 0 1 718.8 762.5L500 543.8Z", + "width": 1000 + }, + "search": [ + "close" + ] + }, + { + "uid": "88ef1cb9cfa36bef1469f2c366078359", + "css": "github", + "code": 59399, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500 83.3A416.7 416.7 0 0 0 83.3 500C83.3 684.2 202.9 840.4 368.3 895.8 389.2 899.2 395.8 886.2 395.8 875V804.6C280.4 829.6 255.8 748.7 255.8 748.7 236.7 700.4 209.6 687.5 209.6 687.5 171.7 661.7 212.5 662.5 212.5 662.5 254.2 665.4 276.3 705.4 276.3 705.4 312.5 768.7 373.8 750 397.5 740 401.3 712.9 412.1 694.6 423.8 684.2 331.3 673.7 234.2 637.9 234.2 479.2 234.2 432.9 250 395.8 277.1 366.2 272.9 355.8 258.3 312.5 281.3 256.2 281.3 256.2 316.3 245 395.8 298.7 428.8 289.6 464.6 285 500 285 535.4 285 571.3 289.6 604.2 298.7 683.8 245 718.8 256.2 718.8 256.2 741.7 312.5 727.1 355.8 722.9 366.2 750 395.8 765.8 432.9 765.8 479.2 765.8 638.3 668.3 673.3 575.4 683.8 590.4 696.7 604.2 722.1 604.2 760.8V875C604.2 886.2 610.8 899.6 632.1 895.8 797.5 840 916.7 684.2 916.7 500A416.7 416.7 0 0 0 500 83.3Z", + "width": 1000 + }, + "search": [ + "github" + ] + }, + { + "uid": "aaf882449b7489944da623680b0f6583", + "css": "group", + "code": 59400, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M70.8 833.3A30.4 30.4 0 0 1 39.6 802.1V735.4C39.6 711.1 45.8 689.1 58.3 669.3 70.8 649.5 88.2 634.7 110.4 625 161.1 602.8 206.8 586.8 247.4 577.1 288 567.4 329.9 562.5 372.9 562.5S457.6 567.4 497.9 577.1C538.2 586.8 583.7 602.8 634.4 625A118.9 118.9 0 0 1 706.2 735.4V802.1A30.4 30.4 0 0 1 675 833.3H70.8ZM745.8 833.3C752.8 832 758.3 828.3 762.5 822.4A37.9 37.9 0 0 0 768.7 800V735.4C768.7 691.7 757.6 655.8 735.4 627.6 713.2 599.5 684 576.7 647.9 559.4 695.8 564.9 741 573.1 783.3 583.8 825.7 594.6 860.1 607 886.5 620.8 909.4 634 927.4 650.3 940.6 669.8 953.8 689.2 960.4 711.1 960.4 735.4V802.1A30.4 30.4 0 0 1 929.2 833.3H745.8ZM372.9 499C327.1 499 289.6 484.4 260.4 455.2 231.2 426 216.7 388.5 216.7 342.7S231.2 259.4 260.4 230.2C289.6 201 327.1 186.5 372.9 186.5S456.2 201 485.4 230.2C514.6 259.4 529.2 296.9 529.2 342.7S514.6 426 485.4 455.2C456.2 484.4 418.7 499 372.9 499ZM747.9 342.7C747.9 388.5 733.3 426 704.2 455.2 675 484.4 637.5 499 591.7 499 584 499 575.5 498.5 566.2 497.4A110.3 110.3 0 0 1 540.6 491.7C557.3 474.3 570 452.9 578.6 427.6 587.3 402.3 591.7 374 591.7 342.7S587.3 283.8 578.6 259.9A221.4 221.4 0 0 0 540.6 193.8 219.8 219.8 0 0 1 591.7 186.5C637.5 186.5 675 201 704.2 230.2 733.3 259.4 747.9 296.9 747.9 342.7ZM102.1 770.8H643.7V735.4C643.7 724.3 640.4 713.5 633.9 703.1A54.5 54.5 0 0 0 609.4 681.3C559.4 659 517.4 644.1 483.3 636.5 449.3 628.8 412.5 625 372.9 625S296.4 628.8 262 636.5C227.6 644.1 185.4 659 135.4 681.3A51.7 51.7 0 0 0 111.5 703.1C105.2 713.5 102.1 724.3 102.1 735.4V770.8ZM372.9 436.5C400 436.5 422.4 427.6 440.1 409.9 457.8 392.2 466.7 369.8 466.7 342.7 466.7 315.6 457.8 293.2 440.1 275.5 422.4 257.8 400 249 372.9 249S323.5 257.8 305.7 275.5C288 293.2 279.2 315.6 279.2 342.7S288 392.2 305.7 409.9C323.5 427.6 345.8 436.5 372.9 436.5Z", + "width": 1000 + }, + "search": [ + "group" + ] + }, + { + "uid": "4c3c6bb09d7bb1d8b059ced4b29776da", + "css": "mail", + "code": 59402, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M145.8 833.3C129.2 833.3 114.6 827.1 102.1 814.6 89.6 802.1 83.3 787.5 83.3 770.8V229.2C83.3 212.5 89.6 197.9 102.1 185.4 114.6 172.9 129.2 166.7 145.8 166.7H854.2C870.8 166.7 885.4 172.9 897.9 185.4 910.4 197.9 916.7 212.5 916.7 229.2V770.8C916.7 787.5 910.4 802.1 897.9 814.6 885.4 827.1 870.8 833.3 854.2 833.3H145.8ZM854.2 286.5L516.7 507.3A149.6 149.6 0 0 1 508.8 510.9 22.5 22.5 0 0 1 500 512.5 22.5 22.5 0 0 1 491.1 510.9 149.6 149.6 0 0 1 483.3 507.3L145.8 286.5V770.8H854.2V286.5ZM500 456.2L850 229.2H151L500 456.2ZM145.8 293.7V252.8 253.5 229.2 253.1 252.2 293.8Z", + "width": 1000 + }, + "search": [ + "mail" + ] + }, + { + "uid": "78967f2650ff2e67992fd39687ba1267", + "css": "language", + "code": 59401, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500 916.7C441.7 916.7 387.2 905.7 336.5 883.8A420.8 420.8 0 0 1 204.2 794.8 412.1 412.1 0 0 1 115.6 662 417.8 417.8 0 0 1 83.3 497.9C83.3 439.6 94.1 385.2 115.6 334.9A415.2 415.2 0 0 1 204.2 203.1 406.7 406.7 0 0 1 336.5 115.1C387.2 93.9 441.7 83.3 500 83.3S612.8 93.9 663.5 115.1A406.7 406.7 0 0 1 795.8 203.1C833.3 240.6 862.8 284.5 884.4 334.9 905.9 385.2 916.7 439.6 916.7 497.9S905.9 611 884.4 662A412.2 412.2 0 0 1 795.8 794.8C758.3 832.3 714.3 862 663.5 883.8 612.8 905.7 558.3 916.7 500 916.7ZM500 856.3A346.7 346.7 0 0 0 561 770.3C577.3 738 590.6 699.7 601 655.2H400C409.7 696.9 422.7 734.4 439.1 767.7 455.4 801 475.7 830.5 500 856.3ZM411.5 843.8A498 498 0 0 1 366.7 758.3C354.2 727.8 343.8 693.4 335.4 655.2H179.2C205.5 704.5 236.1 743.2 270.8 771.3 305.5 799.5 352.4 823.6 411.5 843.8ZM589.6 842.7C639.6 826.8 684.6 802.8 724.5 770.8A373.1 373.1 0 0 0 820.8 655.2H665.6C656.6 692.7 646 726.8 633.9 757.3A472.8 472.8 0 0 1 589.6 842.7ZM158.3 592.7H324C321.9 574 320.6 557.1 320.3 542.2A1905 1905 0 0 1 319.8 497.9C319.8 480.5 320.1 465.1 320.8 451.6 321.5 438 322.9 422.9 325 406.3H158.3C153.5 422.9 150.2 437.8 148.5 451A365 365 0 0 0 145.8 497.9C145.8 516 146.7 532.1 148.5 546.4 150.2 560.6 153.5 576 158.3 592.7ZM388.5 592.7H612.5C615.3 571.2 617 553.6 617.7 540.1A822.8 822.8 0 0 0 617.7 457.8C617 445 615.3 427.8 612.5 406.3H388.5C385.7 427.8 384 445 383.3 457.8A824.3 824.3 0 0 0 383.3 540.1C384 553.7 385.7 571.2 388.5 592.7ZM675 592.7H841.7C846.5 576 849.8 560.6 851.6 546.4A403.3 403.3 0 0 0 854.2 497.9C854.2 479.9 853.3 464.3 851.5 451A303.7 303.7 0 0 0 841.7 406.3H676C678.1 430.5 679.5 449.2 680.2 462 680.9 474.8 681.2 486.8 681.2 497.9 681.2 513.2 680.7 527.6 679.7 541.2 678.7 554.7 677.1 571.9 675 592.7ZM664.6 343.8H820.8A337.5 337.5 0 0 0 726.6 224 359.2 359.2 0 0 0 588.5 156.3C605.9 182 620.6 209.7 632.8 239.6 645 269.5 655.5 304.2 664.6 343.8ZM400 343.8H602.1A406.3 406.3 0 0 0 563.5 237 426.1 426.1 0 0 0 500 145.8C477.8 164.6 459 189.2 443.8 219.8 428.5 250.3 413.9 291.7 400 343.8ZM179.2 343.8H336.5C344.1 306.3 353.8 272.8 365.6 243.3A494.6 494.6 0 0 1 410.4 157.3C358.3 170.5 312.8 192.7 274 224 235.1 255.2 203.5 295.1 179.2 343.8Z", + "width": 1000 + }, + "search": [ + "language" + ] + }, + { + "uid": "69bbd5f578d332bc174ecd414fa33c49", + "css": "moon", + "code": 59403, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500 875C395.8 875 307.3 838.5 234.4 765.6S125 604.2 125 500C125 406.3 152.6 326.7 207.8 261.5 263 196.2 335.4 154.2 425 135.4 453.5 129.9 472.9 134.7 483.3 150S493.4 186.1 482.3 212.5C476 228.5 471.2 244.8 467.7 261.5 464.2 278.1 462.5 295.1 462.5 312.5 462.5 375 484.4 428.1 528.1 471.9 571.9 515.6 625 537.5 687.5 537.5 704.9 537.5 721.7 536 738 532.8 754.3 529.7 770.1 525.3 785.4 519.8 815.3 508.7 837.5 509.2 852.1 521.3 866.7 533.5 870.5 554.2 863.5 583.3 844.8 667.4 802.8 737 737.5 792.2 672.2 847.4 593 875 500 875ZM500 812.5C575.7 812.5 641.7 789 697.9 742.2S789.3 640.3 803.1 577.1C785.7 584.7 767.1 590.4 747.2 594.3A316.1 316.1 0 0 1 687.5 600C607.8 600 540 572 484 516 428 460 400 392.1 400 312.5 400 295.8 401.7 277.9 405.2 258.8A391.3 391.3 0 0 1 424 193.7C355.9 212.5 299.5 250.5 254.7 307.8 209.9 365.1 187.5 429.2 187.5 500 187.5 586.8 217.9 660.6 278.7 721.4 339.4 782.1 413.2 812.5 500 812.5Z", + "width": 1000 + }, + "search": [ + "moon" + ] + }, + { + "uid": "34e76ff7b908864dd94e9673d15632ad", + "css": "policy", + "code": 59404, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500.2 589.6A90 90 0 0 0 565.6 563C583.7 545.3 592.7 523.4 592.7 497.4 592.7 471.4 583.6 449.3 565.4 431.3A89.8 89.8 0 0 0 499.8 404.2C474.3 404.2 452.4 413.2 434.4 431.3A90 90 0 0 0 407.3 497.4C407.3 523.4 416.4 545.3 434.6 563 452.8 580.7 474.7 589.6 500.2 589.6ZM500 852.1C532.6 841 566 823.4 600 799.5 634 775.5 661.5 748.2 682.3 717.7L586.5 626A223.3 223.3 0 0 1 544.6 644.8C529.9 649.7 515 652.1 500 652.1 457 652.1 420.3 637.2 390.1 607.3 359.9 577.4 344.8 540.8 344.8 497.4 344.8 454 359.9 417.2 390.1 387 420.3 356.7 456.9 341.7 500 341.7S579.7 356.7 609.9 387C640.1 417.2 655.2 454.2 655.2 497.9 655.2 512.5 653.1 527.1 649 541.7 644.8 556.3 638.2 569.5 629.2 581.2L716.7 664.6C733.3 634.7 746.5 601.9 756.3 566.1 766 530.2 770.8 493.7 770.8 456.2V249.5L500 151 229.2 249.5V456.2C229.2 547.2 254.3 629.3 304.7 702.6 355 775.8 420.1 825.7 500 852.1ZM499.8 914.6C497.2 914.6 494.7 914.4 492.5 914.1A31.5 31.5 0 0 1 486.5 912.5C393.4 884.7 316.9 827.6 256.7 741.2 196.7 654.7 166.7 559.7 166.7 456.2V252.1C166.7 238.7 170.4 226.7 178 216 185.5 205.3 195.3 197.5 207.3 192.7L478.1 91.7C485.8 88.9 493 87.5 500 87.5 507 87.5 514.3 88.9 521.9 91.7L792.7 192.7C804.7 197.5 814.5 205.3 822 216 829.5 226.7 833.3 238.7 833.3 252.1V456.2C833.3 559.7 803.3 654.7 743.2 741.2 683.2 827.6 606.6 884.7 513.5 912.5A33.9 33.9 0 0 1 507.2 914.1 49.7 49.7 0 0 1 499.8 914.6Z", + "width": 1000 + }, + "search": [ + "policy" + ] + }, + { + "uid": "bd44b46157a19206e67d38deb40be542", + "css": "scale", + "code": 59405, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M737.5 652.1H632.3A30.2 30.2 0 0 0 610 661.1 30.5 30.5 0 0 0 601 683.5 30.5 30.5 0 0 0 632.3 714.6H768.7C777.6 714.6 785 711.6 791 705.6A30.2 30.2 0 0 0 800 683.3V543.7A30.2 30.2 0 0 0 791 521.5 30.5 30.5 0 0 0 768.6 512.5 30.5 30.5 0 0 0 737.5 543.7V652.1ZM263.5 347.9H368.7C377.6 347.9 385 344.9 391 338.9A30.5 30.5 0 0 0 400 316.5 30.5 30.5 0 0 0 368.7 285.4H232.3A30.2 30.2 0 0 0 210 294.4 30.2 30.2 0 0 0 201 316.7V456.2C201 465.1 204 472.5 210.1 478.5A30.5 30.5 0 0 0 232.5 487.5 30.5 30.5 0 0 0 263.5 456.2V347.9ZM145.8 833.3C129.2 833.3 114.6 827.1 102.1 814.6 89.6 802.1 83.3 787.5 83.3 770.8V229.2C83.3 212.5 89.6 197.9 102.1 185.4 114.6 172.9 129.2 166.7 145.8 166.7H854.2C870.8 166.7 885.4 172.9 897.9 185.4 910.4 197.9 916.7 212.5 916.7 229.2V770.8C916.7 787.5 910.4 802.1 897.9 814.6 885.4 827.1 870.8 833.3 854.2 833.3H145.8ZM145.8 770.8H854.2V229.2H145.8V770.8Z", + "width": 1000 + }, + "search": [ + "scale" + ] + }, + { + "uid": "ff40a64140da6114a67777d03d55a377", + "css": "settings", + "code": 59406, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M568.8 916.7H431.3A31 31 0 0 1 410.9 909.4 30.6 30.6 0 0 1 400 890.6L383.3 785.4A279.3 279.3 0 0 1 341.7 765.6C327.1 757.3 314.3 748.6 303.1 739.6L206.3 784.4A31 31 0 0 1 183.3 786 30.2 30.2 0 0 1 165.6 770.8L96.9 649A27.1 27.1 0 0 1 93.8 627.1 34.8 34.8 0 0 1 106.3 608.3L195.8 542.7A123.8 123.8 0 0 1 193.2 521.3 496.8 496.8 0 0 1 192.7 500C192.7 493.7 192.9 486.7 193.2 478.7A123.8 123.8 0 0 1 195.8 457.3L106.2 391.7A34.8 34.8 0 0 1 93.8 372.9 27.1 27.1 0 0 1 96.9 351L165.6 229.2A30.2 30.2 0 0 1 183.3 214.1 31 31 0 0 1 206.3 215.6L303.1 260.4A286.3 286.3 0 0 1 341.7 234.4C356.3 226 370.1 219.8 383.3 215.6L400 109.4A30.6 30.6 0 0 1 410.9 90.6 31 31 0 0 1 431.2 83.3H568.7C576.4 83.3 583.2 85.7 589 90.6 595 95.5 598.6 101.7 600 109.4L616.7 214.6C629.9 219.5 643.9 225.8 658.8 233.8 673.8 241.8 686.5 250.7 696.9 260.4L793.7 215.6A31 31 0 0 1 816.7 214.1C824.3 216.5 830.2 221.5 834.4 229.2L903.1 350C907.3 357 908.5 364.4 906.7 372.4A30.9 30.9 0 0 1 893.7 391.7L804.2 455.2C805.5 462.2 806.4 469.6 806.8 477.6A519.7 519.7 0 0 1 806.8 521.9C806.4 529.5 805.5 536.8 804.2 543.8L893.7 608.3C900 613.2 904.2 619.5 906.2 627.1A27.1 27.1 0 0 1 903.1 649L834.4 770.8A30.2 30.2 0 0 1 816.7 786 31 31 0 0 1 793.7 784.4L696.9 739.6C685.7 748.6 673.1 757.5 658.8 766.1A181.7 181.7 0 0 1 616.7 785.4L600 890.6A30.7 30.7 0 0 1 589 909.4 31 31 0 0 1 568.7 916.7ZM500 635.4C537.5 635.4 569.5 622.2 595.8 595.8 622.2 569.5 635.4 537.5 635.4 500 635.4 462.5 622.2 430.5 595.8 404.2A130.5 130.5 0 0 0 500 364.6C462.5 364.6 430.5 377.8 404.2 404.2 377.8 430.5 364.6 462.5 364.6 500 364.6 537.5 377.8 569.5 404.2 595.8 430.5 622.2 462.5 635.4 500 635.4ZM500 572.9C479.9 572.9 462.7 565.8 448.4 551.6A70.3 70.3 0 0 1 427.1 500C427.1 479.9 434.2 462.7 448.4 448.4A70.3 70.3 0 0 1 500 427.1C520.1 427.1 537.3 434.2 551.6 448.4 565.8 462.7 572.9 479.9 572.9 500S565.8 537.3 551.6 551.6A70.3 70.3 0 0 1 500 572.9ZM454.2 854.2H545.8L560.4 737.5A245.7 245.7 0 0 0 625.5 711.5 267 267 0 0 0 681.3 668.8L791.7 716.7 833.3 641.7 735.4 569.8C738.2 558 740.4 546.3 742.2 534.9 743.9 523.5 744.8 511.8 744.8 500 744.8 488.2 744.1 476.5 742.7 465.1A216 216 0 0 0 735.4 430.2L833.3 358.3 791.7 283.3 681.3 331.2A232.3 232.3 0 0 0 627.1 285.9 175.7 175.7 0 0 0 560.4 262.5L545.8 145.8H454.2L439.6 262.5A220.7 220.7 0 0 0 373.4 287.5C353 299.3 334.7 313.9 318.8 331.2L208.3 283.3 166.7 358.3 264.6 430.2C261.8 442 259.6 453.7 257.8 465.1A232.6 232.6 0 0 0 257.8 534.9C259.5 546.4 261.8 558 264.6 569.8L166.7 641.7 208.3 716.7 318.8 668.8C335.4 685.4 354 699.7 374.5 711.5A245.7 245.7 0 0 0 439.6 737.5L454.2 854.2Z", + "width": 1000 + }, + "search": [ + "settings" + ] + }, + { + "uid": "2d544469b02d76ac0d5f2b40f880dde4", + "css": "speed", + "code": 59407, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M426 661.5C442 677.4 465.1 684.9 495.3 683.9 525.5 682.8 547.6 671.5 561.5 650L747.9 358.3C752.8 350.7 752.3 343.9 746.4 338 740.5 332.1 733.7 331.6 726 336.5L437.5 525C416.7 538.9 405.5 561.1 404.2 591.7 402.8 622.2 410.1 645.5 426 661.5ZM202.1 833.3A75.4 75.4 0 0 1 167.2 824.5 56.7 56.7 0 0 1 141.7 799C123.6 765.6 109.7 731.8 100 697.4A402.1 402.1 0 0 1 85.4 587.5C85.4 529.9 96.3 475.5 118.2 424.5A425.5 425.5 0 0 1 207.3 291.2C244.8 253.3 288.7 223.3 339 201A394.1 394.1 0 0 1 500 167.7C531.3 167.7 563.9 171.7 597.9 179.7A385.6 385.6 0 0 1 699 219.8C712.8 227.4 720.5 236.6 721.9 247.4A27.9 27.9 0 0 1 711.5 274 41.3 41.3 0 0 1 692.2 281.8 31.5 31.5 0 0 1 671.9 278.1 388.3 388.3 0 0 0 581.2 242.2C550 234.2 522.9 230.2 500 230.2 402.8 230.2 319.8 264.9 251 334.4 182.3 403.8 147.9 488.2 147.9 587.5 147.9 618.8 152.3 650.3 160.9 682.3A358.3 358.3 0 0 0 197.9 770.8H801C816.3 745.8 828.5 716.7 837.5 683.3 846.5 650 851 617.4 851 585.4 851 559.7 847.7 532.3 841.1 503.1A279 279 0 0 0 807.3 419.8 29.6 29.6 0 0 1 803.1 398.5 31.4 31.4 0 0 1 813.5 380.2 29.2 29.2 0 0 1 838.5 373.5 29.8 29.8 0 0 1 859.4 388.5C876.8 420.5 889.9 452.3 899 483.9A375.4 375.4 0 0 1 913.5 579.2C914.3 620.8 909.9 660.1 900.5 696.9A379 379 0 0 1 858.3 799C850 814.9 841.1 824.7 831.7 828.1A101.2 101.2 0 0 1 796.9 833.3H202.1Z", + "width": 1000 + }, + "search": [ + "speed" + ] + }, + { + "uid": "917a48ab68c21b00b370f3bbb789a8d6", + "css": "sun", + "code": 59408, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M499.7 645.8C540.2 645.8 574.7 631.7 603.1 603.3 631.6 575.1 645.8 540.7 645.8 500.3 645.8 459.8 631.7 425.3 603.3 396.9 575.1 368.4 540.7 354.2 500.3 354.2 459.8 354.2 425.3 368.3 396.9 396.7 368.4 424.9 354.2 459.3 354.2 499.7 354.2 540.2 368.3 574.7 396.7 603.1 424.9 631.6 459.3 645.8 499.7 645.8ZM500 708.3C442.4 708.3 393.2 688 352.6 647.4 312 606.8 291.7 557.6 291.7 500S312 393.2 352.6 352.6C393.3 312 442.4 291.7 500 291.7S606.8 312 647.4 352.6C688 393.3 708.3 442.4 708.3 500S688 606.8 647.4 647.4C606.8 688 557.6 708.3 500 708.3ZM72.9 531.3A30.1 30.1 0 0 1 50.7 522.2 30.5 30.5 0 0 1 41.7 499.8 30.5 30.5 0 0 1 72.9 468.7H177.1C186 468.7 193.3 471.7 199.3 477.8A30.5 30.5 0 0 1 208.3 500.2 30.5 30.5 0 0 1 177.1 531.3H72.9ZM822.9 531.3A30.2 30.2 0 0 1 800.7 522.2 30.5 30.5 0 0 1 791.7 499.8 30.5 30.5 0 0 1 822.9 468.7H927.1C935.9 468.7 943.3 471.7 949.3 477.8A30.5 30.5 0 0 1 958.3 500.2 30.5 30.5 0 0 1 927.1 531.3H822.9ZM499.8 208.3A30.5 30.5 0 0 1 468.8 177.1V72.9C468.8 64.1 471.7 56.7 477.8 50.7A30.5 30.5 0 0 1 500.2 41.7 30.5 30.5 0 0 1 531.3 72.9V177.1C531.3 186 528.3 193.3 522.2 199.3A30.5 30.5 0 0 1 499.8 208.3ZM499.8 958.3A30.5 30.5 0 0 1 468.8 927.1V822.9C468.8 814.1 471.7 806.7 477.8 800.7A30.5 30.5 0 0 1 500.2 791.7 30.5 30.5 0 0 1 531.3 822.9V927.1C531.3 935.9 528.3 943.3 522.2 949.3A30.5 30.5 0 0 1 499.8 958.3ZM250 293.8L190.6 235.4A29.4 29.4 0 0 1 181.6 212.9 33.5 33.5 0 0 1 190.5 190.5 30.5 30.5 0 0 1 212.9 181.2C221.7 181.2 229.2 184.4 235.4 190.6L293.8 250C299.3 256.3 302.1 263.5 302.1 271.9S299.3 287.3 293.8 293.2A28 28 0 0 1 272.4 302.1 32.6 32.6 0 0 1 250 293.8ZM764.6 809.4L706.2 750A32.4 32.4 0 0 1 697.9 727.8C697.9 719.1 700.8 712 706.8 706.3A28.3 28.3 0 0 1 728.1 696.9C736.5 696.9 743.8 700 750 706.3L809.4 764.6C815.6 770.8 818.6 778.3 818.4 787.1A33.5 33.5 0 0 1 809.5 809.5 30.5 30.5 0 0 1 787.1 818.8 30.7 30.7 0 0 1 764.6 809.4ZM706.2 293.8A30 30 0 0 1 696.9 271.9C696.9 263.5 700 256.2 706.2 250L764.6 190.6C770.8 184.4 778.3 181.4 787.1 181.6 795.8 181.9 803.3 184.8 809.5 190.5A30.5 30.5 0 0 1 818.7 212.9C818.7 221.7 815.6 229.2 809.4 235.4L750 293.8A28.7 28.7 0 0 1 728.7 302.1 33.4 33.4 0 0 1 706.2 293.8ZM190.5 809.5A30.5 30.5 0 0 1 181.2 787.1C181.2 778.3 184.4 770.8 190.6 764.6L250 706.2A29.4 29.4 0 0 1 271.8 696.9 28.7 28.7 0 0 1 293.3 706.2C299.9 712.5 303.1 719.8 303.1 728.1A30 30 0 0 1 293.8 750L235.4 809.4A29.4 29.4 0 0 1 212.9 818.4 33.5 33.5 0 0 1 190.5 809.5Z", + "width": 1000 + }, + "search": [ + "sun" + ] + }, + { + "uid": "346031aa96e7bb2cf1253b02c3a166b8", + "css": "twitter", + "code": 59409, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M935.8 250C903.8 264.6 869.2 274.2 833.3 278.7 870 256.7 898.3 221.7 911.7 179.6 877.1 200.4 838.7 215 798.3 223.3 765.4 187.5 719.2 166.7 666.7 166.7 568.8 166.7 488.8 246.7 488.8 345.4 488.8 359.6 490.4 373.3 493.3 386.2 345 378.8 212.9 307.5 125 199.6 109.6 225.8 100.8 256.7 100.8 289.2 100.8 351.2 132.1 406.3 180.4 437.5 150.8 437.5 123.3 429.2 99.2 416.7V417.9C99.2 504.6 160.8 577.1 242.5 593.3A175.8 175.8 0 0 1 162.1 596.2 178.3 178.3 0 0 0 328.8 720.4 355 355 0 0 1 106.7 797.1C92.5 797.1 78.3 796.2 64.2 794.6 143.3 845.4 237.5 875 338.3 875 666.7 875 847.1 602.5 847.1 366.2 847.1 358.3 847.1 350.8 846.7 342.9 881.7 317.9 911.7 286.2 935.8 250Z", + "width": 1000 + }, + "search": [ + "twitter" + ] + }, + { + "uid": "bd5451543a2703d168d336407bb1bd84", + "css": "warning", + "code": 59410, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M532.2 422.8A20.8 20.8 0 0 0 511.4 401.3H488.5A20.8 20.8 0 0 0 467.6 422.8L471.7 568.3A20.8 20.8 0 0 0 492.5 588.6H507.3A20.8 20.8 0 0 0 528.2 568.3L532.2 422.8ZM471.7 719.3A39 39 0 0 0 500 730.9C507.2 730.9 513.8 729.1 519.8 725.5A41.3 41.3 0 0 0 540 690.9 38.5 38.5 0 0 0 528 663 38.2 38.2 0 0 0 500 651.2 38.7 38.7 0 0 0 471.7 663 37.1 37.1 0 0 0 460.1 690.9 38 38 0 0 0 471.7 719.3ZM445.8 135.4C469.9 93.8 530.1 93.8 554.1 135.4L927 781.3A62.5 62.5 0 0 1 872.8 875H127.2A62.5 62.5 0 0 1 73 781.3L445.9 135.4ZM500 166.7L127.1 812.5H872.9L500 166.7Z", + "width": 1000 + }, + "search": [ + "warning" + ] + }, + { + "uid": "136159e913e70f2c74a6a05ef82ccb9f", + "css": "wifi", + "code": 59411, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M85.7 418.3C107 439.6 140.7 441.7 163.6 422.5 358.2 262.5 640.7 262.5 835.7 422.1 859 441.2 893.2 439.6 914.5 418.3 939 393.7 937.4 352.9 910.3 330.8 672.4 136.2 328.2 136.2 89.9 330.8 62.8 352.5 60.7 393.3 85.7 418.3ZM409 741.7L470.3 802.9C486.5 819.2 512.8 819.2 529 802.9L590.3 741.7C609.9 722.1 605.7 688.3 580.7 675.4A178.3 178.3 0 0 0 417.8 675.4C394 688.3 389.5 722.1 409 741.7ZM253.6 586.2C274 606.7 306.1 608.7 329.9 591.7A294.3 294.3 0 0 1 669.9 591.7C693.6 608.3 725.7 606.7 746.1 586.2L746.5 585.8C771.5 560.8 769.9 518.3 741.1 497.9 597.8 394.2 402.4 394.2 258.6 497.9 229.9 518.7 228.2 560.8 253.6 586.2Z", + "width": 1000 + }, + "search": [ + "wifi" + ] + }, + { + "uid": "8d25df9f6a7ad15b8e3dedaa1dfecce9", + "css": "align_left", + "code": 59412, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M194.5 680.5A26.8 26.8 0 0 1 174.7 672.5 27.1 27.1 0 0 1 166.7 652.6 27.1 27.1 0 0 1 194.5 625H575.9C583.8 625 590.4 627.7 595.7 633A27.1 27.1 0 0 1 603.7 653 27.1 27.1 0 0 1 575.9 680.6H194.5ZM194.5 375A26.8 26.8 0 0 1 174.7 367 27.1 27.1 0 0 1 166.7 347.1 27.1 27.1 0 0 1 194.5 319.5H575.9C583.8 319.5 590.4 322.2 595.7 327.5A27.1 27.1 0 0 1 603.7 347.4 27.1 27.1 0 0 1 575.9 375H194.5ZM194.5 527.8A26.8 26.8 0 0 1 174.7 519.7 27.1 27.1 0 0 1 166.7 499.8 27.1 27.1 0 0 1 194.5 472.2H805.5C813.5 472.2 820 474.9 825.3 480.2A27.1 27.1 0 0 1 833.3 500.2 27.1 27.1 0 0 1 805.5 527.8H194.5ZM194.5 833.3A26.8 26.8 0 0 1 174.7 825.3 27.1 27.1 0 0 1 166.7 805.4 27.1 27.1 0 0 1 194.5 777.8H805.5C813.5 777.8 820 780.5 825.3 785.8A27.1 27.1 0 0 1 833.3 805.8 27.1 27.1 0 0 1 805.5 833.4H194.5ZM194.5 222.2A26.8 26.8 0 0 1 174.7 214.2 27.1 27.1 0 0 1 166.7 194.3 27.1 27.1 0 0 1 194.5 166.7H805.5C813.5 166.7 820 169.3 825.3 174.7A27.1 27.1 0 0 1 833.3 194.6 27.1 27.1 0 0 1 805.5 222.2H194.5Z", + "width": 1000 + }, + "search": [ + "align_left" + ] + }, + { + "uid": "b927f8c892951a5dca7a8476c60f6df9", + "css": "done", + "code": 59392, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M874.9 275.4C887.1 263.3 887.2 243.5 875 231.3 873.5 229.7 871.8 228.4 870.1 227.2 866.1 224.5 861.5 222.9 856.9 222.3 854.6 222 852.3 222 849.9 222.2 843 222.8 836.2 225.8 830.8 231.1L374.1 685.1 169.2 481.6C157 469.4 137.2 469.5 125 481.7 112.8 494 112.9 513.8 125.2 525.9L352 751.3C364.2 763.4 383.9 763.4 396.1 751.3L874.9 275.4Z", + "width": 1000 + }, + "search": [ + "done" + ] + }, + { + "uid": "1375028d46191e43aaabc0d0de0064f4", + "css": "pause", + "code": 59413, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M138.9 1000C77.5 1000 27.8 950.3 27.8 888.9V111.1C27.8 49.7 77.5 0 138.9 0H250C311.4 0 361.1 49.7 361.1 111.1V888.9C361.1 950.3 311.4 1000 250 1000H138.9ZM250 916.7C265.3 916.7 277.8 904.2 277.8 888.9V111.1C277.8 95.8 265.3 83.3 250 83.3H138.9C123.5 83.3 111.1 95.8 111.1 111.1V888.9C111.1 904.2 123.5 916.7 138.9 916.7H250ZM527.8 111.1C527.8 49.7 577.5 0 638.9 0H750C811.4 0 861.1 49.7 861.1 111.1V888.9C861.1 950.3 811.4 1000 750 1000H638.9C577.5 1000 527.8 950.3 527.8 888.9V111.1ZM611.1 888.9C611.1 904.2 623.6 916.7 638.9 916.7H750C765.3 916.7 777.8 904.2 777.8 888.9V111.1C777.8 95.8 765.3 83.3 750 83.3H638.9C623.6 83.3 611.1 95.8 611.1 111.1V888.9Z", + "width": 889 + }, + "search": [ + "pause" + ] + }, + { + "uid": "c9de40434d81771691ed0c67aec2e314", + "css": "play", + "code": 59414, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M222.2 984.8C148.1 1027.9 55.5 974.1 55.5 887.9V112.1C55.5 25.9 148.1-27.9 222.2 15.2L889 403.1C963 446.2 963 553.8 889 596.9L222.2 984.8Z", + "width": 1000 + }, + "search": [ + "play" + ] + }, + { + "uid": "d54e25230fb329a73250116b464a466a", + "css": "x", + "code": 59416, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M791.7 125H208.3C162.3 125 125 162.3 125 208.3V791.7C125 837.7 162.3 875 208.3 875H791.7C837.7 875 875 837.7 875 791.7V208.3C875 162.3 837.7 125 791.7 125ZM714.7 279.9L551.6 466.4 743.4 720.1H593.2L475.5 566.2 340.9 720.1H266.1L440.6 520.6 256.6 279.9H410.6L517 420.6 640 279.9H714.7ZM613.8 675.4H655.2L388.1 322.3H343.7L613.8 675.4Z", + "width": 1000 + }, + "search": [ + "x" + ] + }, + { + "uid": "2b859afa25213bc0a06428cd4e75b016", + "css": "dribbble", + "code": 59417, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500 0C224 0 0 224 0 500 0 776 224 1000 500 1000 775.5 1000 1000 776 1000 500 1000 224 775.5 0 500 0ZM830.3 230.5C891.8 305.5 925.8 399.2 926.8 496.2 912.7 493.5 771.7 464.8 629.6 482.7 626.3 475.6 623.6 468 620.4 460.4 611.6 439.6 602.1 418.9 592.2 398.6 749.5 334.6 821 242.4 830.3 230.5ZM500 73.8C608.5 73.8 707.7 114.5 783.1 181.2 775.5 191.9 710.9 278.2 559.1 335.2 489.1 206.7 411.6 101.4 399.7 85.2 432.5 77.5 466.2 73.6 500 73.8ZM318.4 113.9C375.6 193.2 428.4 275.6 476.7 360.6 277.1 413.8 100.8 412.6 81.9 412.6 95.6 348 124.1 287.4 165.1 235.6 206.2 183.8 258.6 142.1 318.4 113.9ZM72.7 500.5V487.5C91.1 488 298.3 490.8 511.4 426.8 523.9 450.6 535.3 475 546.1 499.4 540.6 501.1 534.7 502.7 529.3 504.3 309.1 575.3 191.9 769.4 182.2 785.8 111.7 707.5 72.6 605.9 72.7 500.5ZM500 927.4C405.1 927.5 312.8 895.8 238.1 837.4 245.7 821.6 332.4 654.5 573.2 570.5 574.3 570 574.9 570 575.9 569.4 616.5 674.2 647 782.6 667 893.1 614.3 915.9 557.4 927.5 500 927.4ZM738 854.1C733.8 828.1 710.9 703.4 655.1 549.9 789.1 528.8 906.2 563.5 920.8 568.4 911.7 626 890.7 681.1 859.3 730.3 827.8 779.5 786.6 821.6 738 854.1Z", + "width": 1000 + }, + "search": [ + "dribbble" + ] + } + ] +} \ No newline at end of file diff --git a/packages/gyver_lamp_icons/resources/fluttericon/fonts/GyverLampIcons.ttf b/packages/gyver_lamp_icons/resources/fluttericon/fonts/GyverLampIcons.ttf new file mode 100644 index 0000000..1b97870 Binary files /dev/null and b/packages/gyver_lamp_icons/resources/fluttericon/fonts/GyverLampIcons.ttf differ diff --git a/packages/gyver_lamp_icons/resources/fluttericon/gyver_lamp_icons_icons.dart b/packages/gyver_lamp_icons/resources/fluttericon/gyver_lamp_icons_icons.dart new file mode 100644 index 0000000..3cf70c0 --- /dev/null +++ b/packages/gyver_lamp_icons/resources/fluttericon/gyver_lamp_icons_icons.dart @@ -0,0 +1,74 @@ +/// Flutter icons GyverLampIcons +/// Copyright (C) 2023 by original authors @ fluttericon.com, fontello.com +/// This font was generated by FlutterIcon.com, which is derived from Fontello. +/// +/// To use this font, place it in your fonts/ directory and include the +/// following in your pubspec.yaml +/// +/// flutter: +/// fonts: +/// - family: GyverLampIcons +/// fonts: +/// - asset: fonts/GyverLampIcons.ttf +/// +/// +/// +import 'package:flutter/widgets.dart'; + +class GyverLampIcons { + GyverLampIcons._(); + + static const _kFontFam = 'GyverLampIcons'; + static const String? _kFontPkg = null; + + static const IconData done = + IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData arrow_left = + IconData(0xe801, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData arrow_outward = + IconData(0xe802, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData arrow_right = + IconData(0xe803, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData chevron_down = + IconData(0xe804, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData chevron_up = + IconData(0xe805, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData close = + IconData(0xe806, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData github = + IconData(0xe807, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData group = + IconData(0xe808, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData language = + IconData(0xe809, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData mail = + IconData(0xe80a, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData moon = + IconData(0xe80b, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData policy = + IconData(0xe80c, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData scale = + IconData(0xe80d, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData settings = + IconData(0xe80e, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData speed = + IconData(0xe80f, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData sun = + IconData(0xe810, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData twitter = + IconData(0xe811, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData warning = + IconData(0xe812, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData wifi = + IconData(0xe813, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData align_left = + IconData(0xe814, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData pause = + IconData(0xe815, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData play = + IconData(0xe816, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData x = + IconData(0xe817, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData dribbble = + IconData(0xe818, fontFamily: _kFontFam, fontPackage: _kFontPkg); +} diff --git a/packages/gyver_lamp_icons/test/gyver_lamp_icons_test.dart b/packages/gyver_lamp_icons/test/gyver_lamp_icons_test.dart new file mode 100644 index 0000000..91e0956 --- /dev/null +++ b/packages/gyver_lamp_icons/test/gyver_lamp_icons_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; + +void main() { + group('GyverLampIcons', () { + test('fontFamily is correct', () { + expect(GyverLampIcons.fontFamily, equals('GyverLampIcons')); + }); + + test('fontPackage is correct', () { + expect(GyverLampIcons.fontPackage, equals('gyver_lamp_icons')); + }); + + testWidgets('can be used', (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Icon(GyverLampIcons.close), + ), + ), + ); + + expect(find.byIcon(GyverLampIcons.close), findsOneWidget); + }); + }); +} diff --git a/packages/gyver_lamp_ui/.gitignore b/packages/gyver_lamp_ui/.gitignore new file mode 100644 index 0000000..b745238 --- /dev/null +++ b/packages/gyver_lamp_ui/.gitignore @@ -0,0 +1,46 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VSCode related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +pubspec.lock +macos/ +ios/ +.metadata + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Test related +coverage \ No newline at end of file diff --git a/packages/gyver_lamp_ui/README.md b/packages/gyver_lamp_ui/README.md new file mode 100644 index 0000000..b42de45 --- /dev/null +++ b/packages/gyver_lamp_ui/README.md @@ -0,0 +1,25 @@ +# Gyver Lamp UI + +[![coverage][coverage_badge]][coverage_badge] +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +UI Toolkit for Gyver Lamp. + +--- + +## Running the Gallery app 🚀 + +This package contains the Gallery app with showcases of the theme and widgets. + +To run the app use the following command: + +```sh +$ flutter run --target gallery/lib/main.dart +``` + +[coverage_badge]: https://raw.githubusercontent.com/VeryGoodOpenSource/very_good_cli/main/coverage_badge.svg +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT \ No newline at end of file diff --git a/packages/gyver_lamp_ui/analysis_options.yaml b/packages/gyver_lamp_ui/analysis_options.yaml new file mode 100644 index 0000000..84e34fb --- /dev/null +++ b/packages/gyver_lamp_ui/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.4.0.0.yaml diff --git a/packages/gyver_lamp_ui/assets/fonts/Inter-Regular.ttf b/packages/gyver_lamp_ui/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..8d4eebf Binary files /dev/null and b/packages/gyver_lamp_ui/assets/fonts/Inter-Regular.ttf differ diff --git a/packages/gyver_lamp_ui/coverage_badge.svg b/packages/gyver_lamp_ui/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/packages/gyver_lamp_ui/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/gyver_lamp_ui/gallery/.gitignore b/packages/gyver_lamp_ui/gallery/.gitignore new file mode 100644 index 0000000..cf0bc35 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/.gitignore @@ -0,0 +1,47 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +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 +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +android/ +ios/ +macos/ + +# 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/packages/gyver_lamp_ui/gallery/README.md b/packages/gyver_lamp_ui/gallery/README.md new file mode 100644 index 0000000..0fdbda8 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/README.md @@ -0,0 +1,16 @@ +# gallery + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/gyver_lamp_ui/gallery/analysis_options.yaml b/packages/gyver_lamp_ui/gallery/analysis_options.yaml new file mode 100644 index 0000000..d767e5d --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:very_good_analysis/analysis_options.4.0.0.yaml +linter: + rules: + public_member_api_docs: false diff --git a/packages/gyver_lamp_ui/gallery/assets/fonts/Figtree-Regular.ttf b/packages/gyver_lamp_ui/gallery/assets/fonts/Figtree-Regular.ttf new file mode 100644 index 0000000..bf935b8 Binary files /dev/null and b/packages/gyver_lamp_ui/gallery/assets/fonts/Figtree-Regular.ttf differ diff --git a/packages/gyver_lamp_ui/gallery/assets/images/button.png b/packages/gyver_lamp_ui/gallery/assets/images/button.png new file mode 100644 index 0000000..43bf4c1 Binary files /dev/null and b/packages/gyver_lamp_ui/gallery/assets/images/button.png differ diff --git a/packages/gyver_lamp_ui/gallery/assets/images/palette.png b/packages/gyver_lamp_ui/gallery/assets/images/palette.png new file mode 100644 index 0000000..61c782d Binary files /dev/null and b/packages/gyver_lamp_ui/gallery/assets/images/palette.png differ diff --git a/packages/gyver_lamp_ui/gallery/assets/images/typography.png b/packages/gyver_lamp_ui/gallery/assets/images/typography.png new file mode 100644 index 0000000..87cac20 Binary files /dev/null and b/packages/gyver_lamp_ui/gallery/assets/images/typography.png differ diff --git a/packages/gyver_lamp_ui/gallery/lib/main.dart b/packages/gyver_lamp_ui/gallery/lib/main.dart new file mode 100644 index 0000000..6e1c9ef --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/main.dart @@ -0,0 +1,22 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:flutter/material.dart'; +import 'package:gallery/stories.dart'; +import 'package:gallery/theme/theme.dart'; + +void main() { + final dashbook = Dashbook( + title: 'Gyver Lamp Dashbook', + theme: ThemeData( + textTheme: GalleryTextStyles.textTheme, + colorScheme: const ColorScheme.light( + primary: GalleryColors.darkBlue, + background: GalleryColors.background, + ), + scaffoldBackgroundColor: GalleryColors.background, + ), + ); + + addStories(dashbook); + + runApp(dashbook); +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories.dart b/packages/gyver_lamp_ui/gallery/lib/stories.dart new file mode 100644 index 0000000..39315dd --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories.dart @@ -0,0 +1,117 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:gallery/stories/colors/colors_story.dart'; +import 'package:gallery/stories/icons/icons_story.dart'; +import 'package:gallery/stories/shadows/shadows_story.dart'; +import 'package:gallery/stories/spacings/spacings_story.dart'; +import 'package:gallery/stories/typography/typography_story.dart'; +import 'package:gallery/stories/widgets/widgets.dart'; + +void addStories(Dashbook dashbook) { + dashbook.storiesOf('Color Palette').add( + 'default', + (_) => const ColorsStory(), + ); + + dashbook.storiesOf('Icons').add( + 'default', + (_) => const IconsStory(), + ); + + dashbook.storiesOf('Shadows').add( + 'default', + (_) => const ShadowsStory(), + ); + + dashbook.storiesOf('Spacings').add( + 'default', + (_) => const SpacingsStory(), + ); + + dashbook.storiesOf('Typography').add( + 'default', + (_) => const TypographyStory(), + ); + + dashbook.storiesOf('Alert messenger').add( + 'default', + (_) => const AlertMessengerStory(), + ); + + dashbook.storiesOf('Circles Wave Loading Indicator').add( + 'default', + (_) => const CirclesWaveLoadingIndicatorStory(), + ); + + dashbook.storiesOf('Confirmation Dialog').add( + 'default', + (_) => const ConfirmationDialogStory(), + ); + + dashbook.storiesOf('Connection Status Badge').add( + 'default', + (_) => const ConnectionStatusBadgeStory(), + ); + + dashbook.storiesOf('App Bar').add( + 'default', + (_) => const CustomAppBarStory(), + ); + + dashbook.storiesOf('Dropdown Button').add( + 'default', + (_) => const CustomDropdownButtonStory(), + ); + + dashbook.storiesOf('Divider').add( + 'default', + (_) => const DividerStory(), + ); + + dashbook.storiesOf('Labeled Input Field').add( + 'default', + (_) => const LabeledInputFieldStory(), + ); + + dashbook.storiesOf('Buttons') + ..add( + 'Flat Icon Button', + (_) => const FlatIconButtonStory(), + ) + ..add( + 'Flat Text Button', + (_) => const FlatTextButtonStory(), + ) + ..add( + 'Rounded Elevated Button', + (_) => const RoundedElevatedButtonStory(), + ) + ..add( + 'Rounded Outlined Button', + (_) => const RoundedOutlinedButtonStory(), + ); + + dashbook.storiesOf('Ruler').add( + 'default', + (_) => const RulerStory(), + ); + + dashbook.storiesOf('Segmented Selector').add( + 'default', + (_) => const SegmentedSelectorStory(), + ); + + dashbook.storiesOf('Settings') + ..add( + 'Setting Tile', + (_) => const SettingTileStory(), + ) + ..add( + 'Settings Group', + (_) => const SettingsGroupStory(), + ); + + dashbook.storiesOf('Switcher').add( + 'default', + (_) => const SwitcherStory(), + ); +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/colors/colors_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/colors/colors_story.dart new file mode 100644 index 0000000..10d0399 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/colors/colors_story.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class ColorsStory extends StatelessWidget { + const ColorsStory({super.key}); + + @override + Widget build(BuildContext context) { + final lightColors = [ + const _ColorCard( + color: GyverLampColors.lightBackground, + name: 'background', + ), + const _ColorCard( + color: GyverLampColors.lightOnBackground, + name: 'on-background', + ), + const _ColorCard( + color: GyverLampColors.lightSurfacePrimary, + name: 'surface-primary', + ), + const _ColorCard( + color: GyverLampColors.lightSurfaceSecondary, + name: 'surface-secondary', + ), + const _ColorCard( + color: GyverLampColors.lightSurfaceVariant, + name: 'surface-variant', + ), + const _ColorCard( + color: GyverLampColors.lightBorderPrimary, + name: 'border-primary', + ), + const _ColorCard( + color: GyverLampColors.lightBorderInput, + name: 'border-input', + ), + const _ColorCard( + color: GyverLampColors.lightTextPrimary, + name: 'text-primary', + ), + const _ColorCard( + color: GyverLampColors.lightTextSecondary, + name: 'text-secondary', + ), + const _ColorCard( + color: GyverLampColors.lightPointer, + name: 'pointer', + ), + const _ColorCard( + color: GyverLampColors.lightConnectedBackground, + name: 'connected-background', + ), + const _ColorCard( + color: GyverLampColors.lightConnectedText, + name: 'connected-text', + ), + const _ColorCard( + color: GyverLampColors.lightConnectingBackground, + name: 'connecting-background', + ), + const _ColorCard( + color: GyverLampColors.lightConnectingText, + name: 'connecting-text', + ), + const _ColorCard( + color: GyverLampColors.lightNotConnectedBackground, + name: 'not-connected-background', + ), + const _ColorCard( + color: GyverLampColors.lightNotConnectedText, + name: 'not-connected-text', + ), + const _ColorCard( + color: GyverLampColors.lightDivider, + name: 'divider', + ), + const _ColorCard( + color: GyverLampColors.lightButtonDisabled, + name: 'button-disabled', + ), + const _ColorCard( + color: GyverLampColors.lightTextButtonDisabled, + name: 'text-button-disabled', + ), + ]; + + final darkColors = [ + const _ColorCard( + color: GyverLampColors.darkBackground, + name: 'background', + ), + const _ColorCard( + color: GyverLampColors.darkOnBackground, + name: 'on-background', + ), + const _ColorCard( + color: GyverLampColors.darkSurfacePrimary, + name: 'surface-primary', + ), + const _ColorCard( + color: GyverLampColors.darkSurfaceSecondary, + name: 'surface-secondary', + ), + const _ColorCard( + color: GyverLampColors.darkSurfaceVariant, + name: 'surface-variant', + ), + const _ColorCard( + color: GyverLampColors.darkBorderPrimary, + name: 'border-primary', + ), + const _ColorCard( + color: GyverLampColors.darkBorderInput, + name: 'border-input', + ), + const _ColorCard( + color: GyverLampColors.darkTextPrimary, + name: 'text-primary', + ), + const _ColorCard( + color: GyverLampColors.darkTextSecondary, + name: 'text-secondary', + ), + const _ColorCard( + color: GyverLampColors.darkPointer, + name: 'pointer', + ), + const _ColorCard( + color: GyverLampColors.darkConnectedBackground, + name: 'connected-background', + ), + const _ColorCard( + color: GyverLampColors.darkConnectedText, + name: 'connected-text', + ), + const _ColorCard( + color: GyverLampColors.darkConnectingBackground, + name: 'connecting-background', + ), + const _ColorCard( + color: GyverLampColors.darkConnectingText, + name: 'connecting-text', + ), + const _ColorCard( + color: GyverLampColors.darkNotConnectedBackground, + name: 'not-connected-background', + ), + const _ColorCard( + color: GyverLampColors.darkNotConnectedText, + name: 'not-connected-text', + ), + const _ColorCard( + color: GyverLampColors.darkDivider, + name: 'divider', + ), + const _ColorCard( + color: GyverLampColors.darkButtonDisabled, + name: 'button-disabled', + ), + const _ColorCard( + color: GyverLampColors.darkTextButtonDisabled, + name: 'text-button-disabled', + ), + ]; + + return StoryScaffold( + title: 'Color Palette', + image: Image.asset('assets/images/palette.png'), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 48, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + const SizedBox(height: 32), + Wrap( + spacing: 48, + runSpacing: 48, + children: lightColors, + ), + const SizedBox(height: 72), + const Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium, + ), + const SizedBox(height: 32), + Wrap( + spacing: 48, + runSpacing: 48, + children: darkColors, + ), + ], + ), + ), + ); + } +} + +class _ColorCard extends StatelessWidget { + const _ColorCard({ + required this.color, + required this.name, + }); + + final Color color; + final String name; + + @override + Widget build(BuildContext context) { + return Container( + height: 285, + width: 195, + decoration: ShapeDecoration( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + shadows: const [ + BoxShadow( + color: GalleryColors.greyBlue, + blurRadius: 24, + offset: Offset(0, 6), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + height: 150, + width: 150, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(12), + ), + ), + const SizedBox(height: 16), + SelectableText( + name, + textAlign: TextAlign.center, + style: GalleryTextStyles.bodyLarge, + ), + const SizedBox(height: 8), + SelectableText( + '0x${color.value.toRadixString(16).toUpperCase()}', + textAlign: TextAlign.center, + style: GalleryTextStyles.bodyMedium, + ), + ], + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/icons/icons_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/icons/icons_story.dart new file mode 100644 index 0000000..b528cd4 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/icons/icons_story.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; + +class IconsStory extends StatelessWidget { + const IconsStory({super.key}); + + @override + Widget build(BuildContext context) { + return const StoryScaffold( + title: 'Icons', + body: SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 48, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + spacing: 32, + runSpacing: 32, + children: [ + _Icon(icon: GyverLampIcons.arrow_left), + _Icon(icon: GyverLampIcons.arrow_outward), + _Icon(icon: GyverLampIcons.arrow_right), + _Icon(icon: GyverLampIcons.chevron_down), + _Icon(icon: GyverLampIcons.chevron_up), + _Icon(icon: GyverLampIcons.close), + _Icon(icon: GyverLampIcons.github), + _Icon(icon: GyverLampIcons.group), + _Icon(icon: GyverLampIcons.language), + _Icon(icon: GyverLampIcons.mail), + _Icon(icon: GyverLampIcons.moon), + _Icon(icon: GyverLampIcons.policy), + _Icon(icon: GyverLampIcons.scale), + _Icon(icon: GyverLampIcons.settings), + _Icon(icon: GyverLampIcons.speed), + _Icon(icon: GyverLampIcons.sun), + _Icon(icon: GyverLampIcons.twitter), + _Icon(icon: GyverLampIcons.warning), + _Icon(icon: GyverLampIcons.wifi), + _Icon(icon: GyverLampIcons.align_left), + _Icon(icon: GyverLampIcons.x), + _Icon(icon: GyverLampIcons.dribbble), + ], + ), + ], + ), + ), + ); + } +} + +class _Icon extends StatelessWidget { + const _Icon({required this.icon}); + + final IconData icon; + + @override + Widget build(BuildContext context) { + return Icon( + icon, + size: 24, + color: Colors.black, + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/shadows/shadows_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/shadows/shadows_story.dart new file mode 100644 index 0000000..e331cb2 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/shadows/shadows_story.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class ShadowsStory extends StatelessWidget { + const ShadowsStory({super.key}); + + @override + Widget build(BuildContext context) { + final lightShadows = [ + _ShadowCard( + brightness: Brightness.light, + shadow: GyverLampShadows.light.shadow1, + name: 'Elevation 1', + description: 'used for input field, dropdown menu, surfaces', + ), + _ShadowCard( + brightness: Brightness.light, + shadow: GyverLampShadows.light.shadow2, + name: 'Elevation 2', + description: 'used for enabled buttons', + ), + _ShadowCard( + brightness: Brightness.light, + shadow: GyverLampShadows.light.shadow3, + name: 'Elevation 3', + description: 'used for overlays', + ), + _ShadowCard( + brightness: Brightness.light, + shadow: GyverLampShadows.light.shadow4, + name: 'Elevation 4', + description: 'used to display the effect', + ), + ]; + + final darkShadows = [ + _ShadowCard( + brightness: Brightness.dark, + shadow: GyverLampShadows.dark.shadow1, + name: 'Elevation 1', + description: 'used for input field, dropdown menu, surfaces', + ), + _ShadowCard( + brightness: Brightness.dark, + shadow: GyverLampShadows.dark.shadow2, + name: 'Elevation 2', + description: 'used for enabled buttons', + ), + _ShadowCard( + brightness: Brightness.dark, + shadow: GyverLampShadows.dark.shadow3, + name: 'Elevation 3', + description: 'used for overlays', + ), + _ShadowCard( + brightness: Brightness.dark, + shadow: GyverLampShadows.dark.shadow4, + name: 'Elevation 4', + description: 'used to display the effect', + ), + ]; + + return StoryScaffold( + title: 'Shadows', + image: Image.asset('assets/images/palette.png'), + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + const SizedBox(height: 32), + Wrap( + spacing: 48, + runSpacing: 48, + children: lightShadows, + ), + ], + ), + ), + ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + Wrap( + spacing: 48, + runSpacing: 48, + children: darkShadows, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _ShadowCard extends StatelessWidget { + const _ShadowCard({ + required this.brightness, + required this.shadow, + required this.name, + required this.description, + }); + + final Brightness brightness; + final BoxShadow shadow; + final String name; + final String description; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 300, + width: 200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + height: 200, + width: 200, + decoration: ShapeDecoration( + color: brightness == Brightness.light + ? Colors.white + : GyverLampColors.darkSurfacePrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + shadows: [shadow], + ), + ), + ), + const SizedBox(height: 16), + SelectableText( + name, + textAlign: TextAlign.start, + style: brightness == Brightness.light + ? GalleryTextStyles.bodyLarge + : GalleryTextStyles.bodyLarge.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 8), + SelectableText( + description, + textAlign: TextAlign.start, + style: brightness == Brightness.light + ? GalleryTextStyles.bodyMedium + : GalleryTextStyles.bodyMedium.copyWith( + color: GyverLampColors.darkTextSecondary, + ), + ), + const Spacer(), + ], + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/spacings/spacings_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/spacings/spacings_story.dart new file mode 100644 index 0000000..f36736a --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/spacings/spacings_story.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class SpacingsStory extends StatelessWidget { + const SpacingsStory({super.key}); + + @override + Widget build(BuildContext context) { + return const StoryScaffold( + title: 'Spacings', + body: SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 48, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SpacingDescription( + spacing: GyverLampSpacings.xxxs, + name: 'xxxs', + ), + GyverLampGaps.xlg, + _SpacingDescription( + spacing: GyverLampSpacings.xxs, + name: 'xxs', + ), + GyverLampGaps.xlg, + _SpacingDescription( + spacing: GyverLampSpacings.xs, + name: 'xs', + ), + GyverLampGaps.xlg, + _SpacingDescription( + spacing: GyverLampSpacings.sm, + name: 'sm', + ), + GyverLampGaps.xlg, + _SpacingDescription( + spacing: GyverLampSpacings.md, + name: 'md', + ), + GyverLampGaps.xlg, + _SpacingDescription( + spacing: GyverLampSpacings.lg, + name: 'lg', + ), + GyverLampGaps.xlg, + _SpacingDescription( + spacing: GyverLampSpacings.xlgsm, + name: 'xlgsm', + ), + GyverLampGaps.xlg, + _SpacingDescription( + spacing: GyverLampSpacings.xlg, + name: 'xlg', + ), + GyverLampGaps.xlg, + _SpacingDescription( + spacing: GyverLampSpacings.xxlg, + name: 'xxlg', + ), + GyverLampGaps.xlg, + _SpacingDescription( + spacing: GyverLampSpacings.xxxlg, + name: 'xxxlg', + ), + ], + ), + ), + ); + } +} + +class _SpacingDescription extends StatelessWidget { + const _SpacingDescription({ + required this.spacing, + required this.name, + }); + + final double spacing; + + final String name; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 75, + child: Align( + alignment: Alignment.centerLeft, + child: SizedBox.square( + dimension: spacing, + child: const ColoredBox(color: Colors.red), + ), + ), + ), + GyverLampGaps.lg, + SelectableText( + name, + style: GalleryTextStyles.bodyLarge, + ), + ], + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/typography/typography_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/typography/typography_story.dart new file mode 100644 index 0000000..355aacc --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/typography/typography_story.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class TypographyStory extends StatelessWidget { + const TypographyStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Typography', + image: Image.asset('assets/images/typography.png'), + trailing: const Text( + 'Font: Inter', + style: GalleryTextStyles.titleLarge, + overflow: TextOverflow.ellipsis, + ), + body: const SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 48, + ), + child: Column( + children: [ + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + 'Regular Style', + style: GalleryTextStyles.headlineMedium, + ), + ), + SizedBox(width: 16), + Expanded( + child: Text( + 'Bold Style', + style: GalleryTextStyles.headlineMedium, + ), + ), + ], + ), + ), + SizedBox(height: 32), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Headline 4', + style: GyverLampTextStyles.headline4, + ), + SizedBox(height: 16), + Text( + 'Headline 5', + style: GyverLampTextStyles.headline5, + ), + SizedBox(height: 16), + Text( + 'Headline 6', + style: GyverLampTextStyles.headline6, + ), + SizedBox(height: 16), + Text( + 'Subtitle 1', + style: GyverLampTextStyles.subtitle1, + ), + SizedBox(height: 16), + Text( + 'Subtitle 2', + style: GyverLampTextStyles.subtitle2, + ), + SizedBox(height: 16), + Text( + 'Body 1', + style: GyverLampTextStyles.body1, + ), + SizedBox(height: 16), + Text( + 'Body 2', + style: GyverLampTextStyles.body2, + ), + SizedBox(height: 16), + Text( + 'Button Large', + style: GyverLampTextStyles.buttonLarge, + ), + SizedBox(height: 16), + Text( + 'Button Medium', + style: GyverLampTextStyles.buttonMedium, + ), + SizedBox(height: 16), + Text( + 'Button Small', + style: GyverLampTextStyles.buttonSmall, + ), + SizedBox(height: 16), + Text( + 'Caption', + style: GyverLampTextStyles.caption, + ), + SizedBox(height: 16), + Text( + 'OVERLINE', + style: GyverLampTextStyles.overline, + ), + ], + ), + ), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Headline 4', + style: GyverLampTextStyles.headline4Bold, + ), + SizedBox(height: 16), + Text( + 'Headline 5', + style: GyverLampTextStyles.headline5Bold, + ), + SizedBox(height: 16), + Text( + 'Headline 6', + style: GyverLampTextStyles.headline6Bold, + ), + SizedBox(height: 16), + Text( + 'Subtitle 1', + style: GyverLampTextStyles.subtitle1Bold, + ), + SizedBox(height: 16), + Text( + 'Subtitle 2', + style: GyverLampTextStyles.subtitle2Bold, + ), + SizedBox(height: 16), + Text( + 'Body 1', + style: GyverLampTextStyles.body1Bold, + ), + SizedBox(height: 16), + Text( + 'Body 2', + style: GyverLampTextStyles.body2Bold, + ), + SizedBox(height: 16), + Text( + 'Button Large', + style: GyverLampTextStyles.buttonLargeBold, + ), + SizedBox(height: 16), + Text( + 'Button Medium', + style: GyverLampTextStyles.buttonMediumBold, + ), + SizedBox(height: 16), + Text( + 'Button Small', + style: GyverLampTextStyles.buttonSmallBold, + ), + SizedBox(height: 16), + Text( + 'Caption', + style: GyverLampTextStyles.captionBold, + ), + SizedBox(height: 16), + Text( + 'OVERLINE', + style: GyverLampTextStyles.overlineBold, + ), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/alert_messenger_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/alert_messenger_story.dart new file mode 100644 index 0000000..9d9139b --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/alert_messenger_story.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class AlertMessengerStory extends StatelessWidget { + const AlertMessengerStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Alert Messenger', + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + SizedBox(height: 32), + SizedBox( + height: 300, + child: _AlertMessenger(), + ), + SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + const SizedBox( + height: 300, + child: _AlertMessenger(), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _AlertMessenger extends StatelessWidget { + const _AlertMessenger(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: Theme.of(context), + home: Scaffold( + body: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: theme.borderInput), + ), + child: Builder( + builder: (context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RoundedElevatedButton.large( + child: const Text('Show info'), + onPressed: () { + AlertMessenger.of(context).showInfo( + message: 'Test info message.', + ); + }, + ), + GyverLampGaps.lg, + RoundedElevatedButton.large( + child: const Text('Show error'), + onPressed: () { + AlertMessenger.of(context).showError( + message: 'Test error message.', + ); + }, + ), + ], + ), + GyverLampGaps.lg, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RoundedOutlinedButton.large( + child: const Text('Hide'), + onPressed: () { + AlertMessenger.of(context).hide(); + }, + ), + GyverLampGaps.lg, + RoundedOutlinedButton.large( + child: const Text('Clear'), + onPressed: () { + AlertMessenger.of(context).clear(); + }, + ), + ], + ), + ], + ); + }, + ), + ), + ), + builder: (context, child) { + return AlertMessenger(child: child!); + }, + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/circles_wave_loading_indicator_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/circles_wave_loading_indicator_story.dart new file mode 100644 index 0000000..0f847b1 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/circles_wave_loading_indicator_story.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class CirclesWaveLoadingIndicatorStory extends StatelessWidget { + const CirclesWaveLoadingIndicatorStory({super.key}); + + @override + Widget build(BuildContext context) { + return const StoryScaffold( + title: 'Circles Wave Loading Indicator', + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CirclesWaveLoadingIndicator(size: 8), + GyverLampGaps.md, + CirclesWaveLoadingIndicator(), + GyverLampGaps.md, + CirclesWaveLoadingIndicator(size: 16), + ], + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/confirmation_dialog_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/confirmation_dialog_story.dart new file mode 100644 index 0000000..3fd243c --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/confirmation_dialog_story.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class ConfirmationDialogStory extends StatelessWidget { + const ConfirmationDialogStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Confirmation Dialog', + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + const SizedBox(height: 32), + RoundedElevatedButton.large( + child: const Text('Show dialog'), + onPressed: () { + GyverLampDialog.show( + context, + dialog: Theme( + data: GyverLampTheme.lightThemeData, + child: const _DisconnectConfirmationDialog(), + ), + ); + }, + ), + const SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + RoundedElevatedButton.large( + child: const Text('Show dialog'), + onPressed: () { + GyverLampDialog.show( + context, + dialog: Theme( + data: GyverLampTheme.darkThemeData, + child: const _DisconnectConfirmationDialog(), + ), + ); + }, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _DisconnectConfirmationDialog extends StatelessWidget { + const _DisconnectConfirmationDialog(); + + @override + Widget build(BuildContext context) { + return ConfirmationDialog( + title: 'Disconnect from Lamp', + body: 'Are you sure you want to disconnect the phone from the lamp?', + cancelLabel: 'Cancel', + confirmLabel: 'Disconnect', + onCancel: () {}, + onConfirm: () {}, + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/connection_status_badge_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/connection_status_badge_story.dart new file mode 100644 index 0000000..e667639 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/connection_status_badge_story.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +String _label(ConnectionStatus status) { + return switch (status) { + (ConnectionStatus.connected) => 'Connected', + (ConnectionStatus.connecting) => 'Connecting', + (ConnectionStatus.notConnected) => 'Not Connected', + }; +} + +class ConnectionStatusBadgeStory extends StatelessWidget { + const ConnectionStatusBadgeStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Connection Status Badge', + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + const SizedBox(height: 32), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + ConnectionStatusBadge( + status: ConnectionStatus.connected, + label: _label, + onPressed: () {}, + ), + const ConnectionStatusBadge( + status: ConnectionStatus.connected, + label: _label, + onPressed: null, + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + ConnectionStatusBadge( + status: ConnectionStatus.connecting, + label: _label, + onPressed: () {}, + ), + const ConnectionStatusBadge( + status: ConnectionStatus.connecting, + label: _label, + onPressed: null, + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + ConnectionStatusBadge( + status: ConnectionStatus.notConnected, + label: _label, + onPressed: () {}, + ), + const ConnectionStatusBadge( + status: ConnectionStatus.notConnected, + label: _label, + onPressed: null, + ), + ], + ), + const SizedBox(height: 24), + const _InteractiveConnectionStatusBadge(), + const SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + ConnectionStatusBadge( + status: ConnectionStatus.connected, + label: _label, + onPressed: () {}, + ), + const ConnectionStatusBadge( + status: ConnectionStatus.connected, + label: _label, + onPressed: null, + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + ConnectionStatusBadge( + status: ConnectionStatus.connecting, + label: _label, + onPressed: () {}, + ), + const ConnectionStatusBadge( + status: ConnectionStatus.connecting, + label: _label, + onPressed: null, + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + ConnectionStatusBadge( + status: ConnectionStatus.notConnected, + label: _label, + onPressed: () {}, + ), + const ConnectionStatusBadge( + status: ConnectionStatus.notConnected, + label: _label, + onPressed: null, + ), + ], + ), + const SizedBox(height: 24), + const _InteractiveConnectionStatusBadge(), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _InteractiveConnectionStatusBadge extends StatefulWidget { + const _InteractiveConnectionStatusBadge(); + + @override + State<_InteractiveConnectionStatusBadge> createState() => + __InteractiveConnectionStatusBadgeState(); +} + +class __InteractiveConnectionStatusBadgeState + extends State<_InteractiveConnectionStatusBadge> { + var _index = 0; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Flexible( + child: ConnectionStatusBadge( + status: ConnectionStatus.values[_index], + label: _label, + onPressed: () { + setState(() { + _index += 1; + _index %= ConnectionStatus.values.length; + }); + }, + ), + ), + ], + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/custom_app_bar_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/custom_app_bar_story.dart new file mode 100644 index 0000000..e37fb35 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/custom_app_bar_story.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class CustomAppBarStory extends StatelessWidget { + const CustomAppBarStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'App Bar', + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + SizedBox(height: 32), + _ActionsAppBar(), + SizedBox(height: 12), + _TitleAppBar(), + SizedBox(height: 12), + _ScrollableAppBar(), + SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + const _ActionsAppBar(), + const SizedBox(height: 12), + const _TitleAppBar(), + const SizedBox(height: 12), + const _ScrollableAppBar(), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _ActionsAppBar extends StatelessWidget { + const _ActionsAppBar(); + + @override + Widget build(BuildContext context) { + return CustomAppBar( + padding: const EdgeInsets.symmetric( + horizontal: GyverLampSpacings.xlgsm, + ), + leading: ConnectionStatusBadge( + status: ConnectionStatus.connected, + label: (_) => 'Connected', + onPressed: () {}, + ), + actions: [ + FlatIconButton.medium( + icon: GyverLampIcons.settings, + onPressed: () {}, + ), + Switcher( + value: true, + onChanged: (_) {}, + ), + ], + ); + } +} + +class _TitleAppBar extends StatelessWidget { + const _TitleAppBar(); + + @override + Widget build(BuildContext context) { + return CustomAppBar( + leading: FlatIconButton.medium( + icon: GyverLampIcons.arrow_left, + onPressed: () {}, + ), + title: 'Title', + ); + } +} + +class _ScrollableAppBar extends StatelessWidget { + const _ScrollableAppBar(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return SizedBox( + height: 200, + child: Scaffold( + appBar: const CustomAppBar( + title: 'Scroll Me', + ), + body: ColoredBox( + color: theme.surfacePrimary, + child: ListView.builder( + itemCount: 30, + itemBuilder: (context, index) { + return Text( + '$index', + textAlign: TextAlign.center, + ); + }, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/custom_dropdown_button_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/custom_dropdown_button_story.dart new file mode 100644 index 0000000..23581e3 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/custom_dropdown_button_story.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class CustomDropdownButtonStory extends StatelessWidget { + const CustomDropdownButtonStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Custom Dropdown Button', + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + SizedBox(height: 32), + _InteractiveCustomDropdownButton(), + SizedBox(height: 200), + SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + const _InteractiveCustomDropdownButton(), + const SizedBox(height: 200), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +enum _Mode { + colorChange, + rainbowVertical, + rainbowHorizontal, + confetti, + fire, + matrix, + clouds, + lava, +} + +class _InteractiveCustomDropdownButton extends StatefulWidget { + const _InteractiveCustomDropdownButton(); + + @override + State<_InteractiveCustomDropdownButton> createState() => + _InteractiveCustomDropdownButtonState(); +} + +class _InteractiveCustomDropdownButtonState + extends State<_InteractiveCustomDropdownButton> { + var _mode = _Mode.colorChange; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: CustomDropdownButton<_Mode>( + items: const [ + CustomDropdownMenuItem( + value: _Mode.colorChange, + label: 'Color Change', + ), + CustomDropdownMenuItem( + value: _Mode.rainbowVertical, + label: 'Rainbow Vertical', + ), + CustomDropdownMenuItem( + value: _Mode.rainbowHorizontal, + label: 'Rainbow Horizontal', + ), + CustomDropdownMenuItem( + value: _Mode.confetti, + label: 'Confetti', + ), + CustomDropdownMenuItem( + value: _Mode.fire, + label: 'Fire', + ), + CustomDropdownMenuItem( + value: _Mode.matrix, + label: 'Matrix', + ), + CustomDropdownMenuItem( + value: _Mode.clouds, + label: 'Clouds', + ), + CustomDropdownMenuItem( + value: _Mode.lava, + label: 'Lava', + ), + ], + selected: _mode, + onChanged: (value) { + setState(() { + _mode = value; + }); + }, + ), + ), + ], + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/divider_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/divider_story.dart new file mode 100644 index 0000000..8dd74d2 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/divider_story.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class DividerStory extends StatelessWidget { + const DividerStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Divider', + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + SizedBox(height: 32), + Divider(), + SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + const Divider(), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/flat_icon_button_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/flat_icon_button_story.dart new file mode 100644 index 0000000..47cae39 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/flat_icon_button_story.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class FlatIconButtonStory extends StatelessWidget { + const FlatIconButtonStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Icon Button', + image: Image.asset('assets/images/button.png'), + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + const SizedBox(height: 32), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + FlatIconButton.medium( + icon: GyverLampIcons.settings, + onPressed: () {}, + ), + const FlatIconButton.medium( + icon: GyverLampIcons.settings, + onPressed: null, + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + FlatIconButton.large( + icon: GyverLampIcons.settings, + onPressed: () {}, + ), + const FlatIconButton.large( + icon: GyverLampIcons.settings, + onPressed: null, + ), + ], + ), + const SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + FlatIconButton.medium( + icon: GyverLampIcons.settings, + onPressed: () {}, + ), + const FlatIconButton.medium( + icon: GyverLampIcons.settings, + onPressed: null, + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + FlatIconButton.large( + icon: GyverLampIcons.settings, + onPressed: () {}, + ), + const FlatIconButton.large( + icon: GyverLampIcons.settings, + onPressed: null, + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/flat_text_button_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/flat_text_button_story.dart new file mode 100644 index 0000000..483f93d --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/flat_text_button_story.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class FlatTextButtonStory extends StatelessWidget { + const FlatTextButtonStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Flat Text Button', + image: Image.asset('assets/images/button.png'), + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + const SizedBox(height: 32), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + FlatTextButton.small( + onPressed: () {}, + child: const Text('Button'), + ), + const FlatTextButton.small( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + FlatTextButton.medium( + onPressed: () {}, + child: const Text('Button'), + ), + const FlatTextButton.medium( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + FlatTextButton.large( + onPressed: () {}, + child: const Text('Button'), + ), + const FlatTextButton.large( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + FlatTextButton.small( + onPressed: () {}, + child: const Text('Button'), + ), + const FlatTextButton.small( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + FlatTextButton.medium( + onPressed: () {}, + child: const Text('Button'), + ), + const FlatTextButton.medium( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + FlatTextButton.large( + onPressed: () {}, + child: const Text('Button'), + ), + const FlatTextButton.large( + onPressed: null, + child: Text('Button'), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/labeled_input_field_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/labeled_input_field_story.dart new file mode 100644 index 0000000..d60c629 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/labeled_input_field_story.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class LabeledInputFieldStory extends StatelessWidget { + const LabeledInputFieldStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Labeled Input Field', + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + SizedBox(height: 32), + _IPField(), + SizedBox(height: 12), + LabeledInputField( + label: 'Enabled Field', + ), + SizedBox(height: 12), + LabeledInputField( + label: 'Disabled Field', + enabled: false, + ), + SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + const _IPField(), + const SizedBox(height: 12), + const LabeledInputField( + label: 'Enabled Label', + ), + const SizedBox(height: 12), + const LabeledInputField( + label: 'Disabled Field', + enabled: false, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _IPField extends StatefulWidget { + const _IPField(); + + @override + State<_IPField> createState() => __IPFieldState(); +} + +class __IPFieldState extends State<_IPField> { + late final _ipRegExp = RegExp(r'^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$'); + + String? _errorText; + + void _validate(String value) { + final hasMatch = _ipRegExp.hasMatch(value); + + if (hasMatch) { + if (_errorText != null) { + setState(() { + _errorText = null; + }); + } + + return; + } + + setState(() { + _errorText = 'Wrong IP format. Example: 192.168.0.1'; + }); + } + + @override + Widget build(BuildContext context) { + return LabeledInputField( + label: 'IP', + hintText: 'XXX.XXX.XXX.XXX', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: _validate, + errorText: _errorText, + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/rounded_elevated_button_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/rounded_elevated_button_story.dart new file mode 100644 index 0000000..af40404 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/rounded_elevated_button_story.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class RoundedElevatedButtonStory extends StatelessWidget { + const RoundedElevatedButtonStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Rounded Elevated Button', + image: Image.asset('assets/images/button.png'), + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + const SizedBox(height: 32), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedElevatedButton.small( + onPressed: () {}, + child: const Text('Button'), + ), + const RoundedElevatedButton.small( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedElevatedButton.medium( + onPressed: () {}, + child: const Text('Button'), + ), + const RoundedElevatedButton.medium( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedElevatedButton.large( + onPressed: () {}, + child: const Text('Button'), + ), + const RoundedElevatedButton.large( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 12), + Builder( + builder: (context) { + final theme = + Theme.of(context).extension()!; + + return Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedElevatedButton.large( + onPressed: () {}, + child: CirclesWaveLoadingIndicator( + color: theme.background, + ), + ), + RoundedElevatedButton.large( + onPressed: null, + child: CirclesWaveLoadingIndicator( + color: theme.background, + ), + ), + ], + ); + }, + ), + const SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedElevatedButton.small( + onPressed: () {}, + child: const Text('Button'), + ), + const RoundedElevatedButton.small( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedElevatedButton.medium( + onPressed: () {}, + child: const Text('Button'), + ), + const RoundedElevatedButton.medium( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedElevatedButton.large( + onPressed: () {}, + child: const Text('Button'), + ), + const RoundedElevatedButton.large( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Builder( + builder: (context) { + final theme = + Theme.of(context).extension()!; + + return Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedElevatedButton.large( + onPressed: () {}, + child: CirclesWaveLoadingIndicator( + color: theme.background, + ), + ), + RoundedElevatedButton.large( + onPressed: null, + child: CirclesWaveLoadingIndicator( + color: theme.background, + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/rounded_outlined_button_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/rounded_outlined_button_story.dart new file mode 100644 index 0000000..c1c3c2e --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/rounded_outlined_button_story.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class RoundedOutlinedButtonStory extends StatelessWidget { + const RoundedOutlinedButtonStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Rounded Outlined Button', + image: Image.asset('assets/images/button.png'), + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + const SizedBox(height: 32), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedOutlinedButton.small( + onPressed: () {}, + child: const Text('Button'), + ), + const RoundedOutlinedButton.small( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedOutlinedButton.medium( + onPressed: () {}, + child: const Text('Button'), + ), + const RoundedOutlinedButton.medium( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedOutlinedButton.large( + onPressed: () {}, + child: const Text('Button'), + ), + const RoundedOutlinedButton.large( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 12), + Builder( + builder: (context) { + final theme = + Theme.of(context).extension()!; + + return Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedOutlinedButton.large( + onPressed: () {}, + child: CirclesWaveLoadingIndicator( + color: theme.textSecondary, + ), + ), + RoundedOutlinedButton.large( + onPressed: null, + child: CirclesWaveLoadingIndicator( + color: theme.textSecondary, + ), + ), + ], + ); + }, + ), + const SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedOutlinedButton.small( + onPressed: () {}, + child: const Text('Button'), + ), + const RoundedOutlinedButton.small( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedOutlinedButton.medium( + onPressed: () {}, + child: const Text('Button'), + ), + const RoundedOutlinedButton.medium( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedOutlinedButton.large( + onPressed: () {}, + child: const Text('Button'), + ), + const RoundedOutlinedButton.large( + onPressed: null, + child: Text('Button'), + ), + ], + ), + const SizedBox(height: 24), + Builder( + builder: (context) { + final theme = + Theme.of(context).extension()!; + + return Wrap( + spacing: 24, + runSpacing: 48, + children: [ + RoundedOutlinedButton.large( + onPressed: () {}, + child: CirclesWaveLoadingIndicator( + color: theme.textSecondary, + ), + ), + RoundedOutlinedButton.large( + onPressed: null, + child: CirclesWaveLoadingIndicator( + color: theme.textSecondary, + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/ruler_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/ruler_story.dart new file mode 100644 index 0000000..f41b980 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/ruler_story.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class RulerStory extends StatelessWidget { + const RulerStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Ruler', + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + SizedBox(height: 32), + _InteractiveRuler(), + SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + const _InteractiveRuler(), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _InteractiveRuler extends StatefulWidget { + const _InteractiveRuler(); + + @override + State<_InteractiveRuler> createState() => _InteractiveRulerState(); +} + +class _InteractiveRulerState extends State<_InteractiveRuler> { + int _currentValue = 1; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Ruler( + value: _currentValue, + maxValue: 255, + onChanged: (value) { + setState(() { + _currentValue = value; + }); + }, + ), + ), + ], + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/segmented_selector_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/segmented_selector_story.dart new file mode 100644 index 0000000..d885b5e --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/segmented_selector_story.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +enum Language { + english, + ukrainian, + russian, +} + +class SegmentedSelectorStory extends StatelessWidget { + const SegmentedSelectorStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Segmented Selector', + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + SizedBox(height: 32), + _Selector(), + SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + const _Selector(), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _Selector extends StatefulWidget { + const _Selector(); + + @override + State<_Selector> createState() => __SelectorState(); +} + +class __SelectorState extends State<_Selector> { + Language _selected = Language.english; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SegmentedSelector( + segments: const [ + SelectorSegment( + value: Language.english, + label: 'EN', + ), + SelectorSegment( + value: Language.ukrainian, + label: 'UA', + ), + SelectorSegment( + value: Language.russian, + label: 'RU', + ), + ], + selected: _selected, + onChanged: (language) { + setState(() { + _selected = language; + }); + }, + ), + ], + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/setting_tile_group_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/setting_tile_group_story.dart new file mode 100644 index 0000000..6c536c6 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/setting_tile_group_story.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class SettingsGroupStory extends StatelessWidget { + const SettingsGroupStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Setting Tile Group', + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + SizedBox(height: 32), + _InteractiveSettingTileGroup(), + SizedBox(height: 12), + _StaticSettingTileGroup(), + SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + const _InteractiveSettingTileGroup(), + const SizedBox(height: 12), + const _StaticSettingTileGroup(), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +enum _Language { + en, + ua, + ru, +} + +class _InteractiveSettingTileGroup extends StatefulWidget { + const _InteractiveSettingTileGroup(); + + @override + State<_InteractiveSettingTileGroup> createState() => + __InteractiveSettingTileGroupState(); +} + +class __InteractiveSettingTileGroupState + extends State<_InteractiveSettingTileGroup> { + _Language _language = _Language.ua; + bool _isDark = false; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: SettingTileGroup( + label: 'General', + tiles: [ + SettingTile( + icon: GyverLampIcons.language, + label: 'Language', + action: StatefulBuilder( + builder: (context, setState) { + return SegmentedSelector<_Language>( + segments: const [ + SelectorSegment( + value: _Language.en, + label: 'EN', + ), + SelectorSegment( + value: _Language.ua, + label: 'UA', + ), + SelectorSegment( + value: _Language.ru, + label: 'RU', + ), + ], + selected: _language, + onChanged: (language) { + setState(() => _language = language); + }, + ); + }, + ), + ), + SettingTile( + icon: GyverLampIcons.moon, + label: 'Dark Mode', + action: StatefulBuilder( + builder: (context, setState) { + return Switcher( + value: _isDark, + onChanged: (isDark) { + setState(() => _isDark = isDark); + }, + ); + }, + ), + ), + ], + ), + ); + } +} + +class _StaticSettingTileGroup extends StatelessWidget { + const _StaticSettingTileGroup(); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: SettingTileGroup( + label: 'Other Stuff', + tiles: [ + SettingTile( + icon: GyverLampIcons.github, + label: 'Lamp Project', + action: FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: () {}, + ), + ), + SettingTile( + icon: GyverLampIcons.group, + label: 'Credits', + action: FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: () {}, + ), + ), + SettingTile( + icon: GyverLampIcons.policy, + label: 'Privacy Policy', + action: FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: () {}, + ), + ), + SettingTile( + icon: GyverLampIcons.align_left, + label: 'Terms of Use', + action: FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: () {}, + ), + ), + ], + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/setting_tile_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/setting_tile_story.dart new file mode 100644 index 0000000..8251f00 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/setting_tile_story.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class SettingTileStory extends StatelessWidget { + const SettingTileStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Setting Tile', + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + SizedBox(height: 32), + _InteractiveDarkModeSettingTile(), + SizedBox(height: 12), + _StaticLinkLaunchSettingTile(), + SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + const _InteractiveDarkModeSettingTile(), + const SizedBox(height: 12), + const _StaticLinkLaunchSettingTile(), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _InteractiveDarkModeSettingTile extends StatefulWidget { + const _InteractiveDarkModeSettingTile(); + + @override + State<_InteractiveDarkModeSettingTile> createState() => + _InteractiveDarkModeSettingTileState(); +} + +class _InteractiveDarkModeSettingTileState + extends State<_InteractiveDarkModeSettingTile> { + bool _isDark = false; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: SettingTile( + icon: GyverLampIcons.moon, + label: 'Dark Mode', + action: StatefulBuilder( + builder: (context, setState) { + return Switcher( + value: _isDark, + onChanged: (isDark) { + setState(() => _isDark = isDark); + }, + ); + }, + ), + ), + ); + } +} + +class _StaticLinkLaunchSettingTile extends StatelessWidget { + const _StaticLinkLaunchSettingTile(); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: SettingTile( + icon: GyverLampIcons.mail, + label: 'Email', + action: FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: () {}, + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/switcher_story.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/switcher_story.dart new file mode 100644 index 0000000..c2d3655 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/switcher_story.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/story_scaffold.dart'; +import 'package:gallery/theme/theme.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +class SwitcherStory extends StatelessWidget { + const SwitcherStory({super.key}); + + @override + Widget build(BuildContext context) { + return StoryScaffold( + title: 'Switcher', + body: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Theme( + data: GyverLampTheme.lightThemeData, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Light Mode', + style: GalleryTextStyles.headlineMedium, + ), + SizedBox(height: 32), + _Switcher(defaultValue: true), + SizedBox(height: 12), + _Switcher(defaultValue: false), + SizedBox(height: 12), + ], + ), + ), + ), + Theme( + data: GyverLampTheme.darkThemeData, + child: ColoredBox( + color: GyverLampColors.darkBackground, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 64, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dark Mode', + style: GalleryTextStyles.headlineMedium.copyWith( + color: GyverLampColors.darkTextPrimary, + ), + ), + const SizedBox(height: 32), + const _Switcher(defaultValue: true), + const SizedBox(height: 12), + const _Switcher(defaultValue: false), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _Switcher extends StatefulWidget { + const _Switcher({required this.defaultValue}); + + final bool defaultValue; + + @override + State<_Switcher> createState() => _SwitcherState(); +} + +class _SwitcherState extends State<_Switcher> { + late bool _enabled = widget.defaultValue; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Switcher( + value: _enabled, + onChanged: (value) { + setState(() { + _enabled = value; + }); + }, + ), + ], + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/stories/widgets/widgets.dart b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/widgets.dart new file mode 100644 index 0000000..4999ac6 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/stories/widgets/widgets.dart @@ -0,0 +1,17 @@ +export 'alert_messenger_story.dart'; +export 'circles_wave_loading_indicator_story.dart'; +export 'confirmation_dialog_story.dart'; +export 'connection_status_badge_story.dart'; +export 'custom_app_bar_story.dart'; +export 'custom_dropdown_button_story.dart'; +export 'divider_story.dart'; +export 'flat_icon_button_story.dart'; +export 'flat_text_button_story.dart'; +export 'labeled_input_field_story.dart'; +export 'rounded_elevated_button_story.dart'; +export 'rounded_outlined_button_story.dart'; +export 'ruler_story.dart'; +export 'segmented_selector_story.dart'; +export 'setting_tile_group_story.dart'; +export 'setting_tile_story.dart'; +export 'switcher_story.dart'; diff --git a/packages/gyver_lamp_ui/gallery/lib/story_scaffold.dart b/packages/gyver_lamp_ui/gallery/lib/story_scaffold.dart new file mode 100644 index 0000000..522859a --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/story_scaffold.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/theme/theme.dart'; + +class StoryScaffold extends StatelessWidget { + const StoryScaffold({ + required this.title, + required this.body, + this.backgroundColor, + this.image, + this.trailing, + super.key, + }); + + final String title; + final Widget body; + final Color? backgroundColor; + final Image? image; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: backgroundColor, + appBar: _AppBar( + image: image, + title: title, + trailing: trailing, + ), + body: body, + ); + } +} + +class _AppBar extends StatelessWidget implements PreferredSizeWidget { + const _AppBar({ + required this.title, + this.image, + this.trailing, + }); + + final String title; + final Image? image; + final Widget? trailing; + + @override + Size get preferredSize => const Size.fromHeight(115); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 64), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Row( + children: [ + if (image != null) ...[ + SizedBox.square( + dimension: 56, + child: image, + ), + const SizedBox(width: 16), + ], + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + title, + style: GalleryTextStyles.headlineLarge, + overflow: TextOverflow.ellipsis, + softWrap: true, + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 16), + Flexible( + child: trailing!, + ), + ], + ], + ), + ), + ], + ), + const Spacer(), + const Divider( + height: 2, + thickness: 2, + ), + ], + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/gallery/lib/theme/colors.dart b/packages/gyver_lamp_ui/gallery/lib/theme/colors.dart new file mode 100644 index 0000000..61638c4 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/theme/colors.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +abstract class GalleryColors { + static const Color background = Color(0xFFF8F9FF); + + static const Color darkBlue = Color(0xFF202A45); + + static const Color greyBlue = Color(0x3F6E85C5); + + static const Color darkGrey = Color(0xFF3A4460); + + static const Color grey = Color(0xFF9B9A9A); + + static const Color darkTeal = Color(0xFF272C2B); +} diff --git a/packages/gyver_lamp_ui/gallery/lib/theme/text_styles.dart b/packages/gyver_lamp_ui/gallery/lib/theme/text_styles.dart new file mode 100644 index 0000000..b829919 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/theme/text_styles.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:gallery/theme/theme.dart'; + +abstract class GalleryTextStyles { + static TextTheme get textTheme => const TextTheme( + headlineLarge: headlineLarge, + headlineMedium: headlineMedium, + titleLarge: bodyLarge, + bodyLarge: bodyMedium, + ); + + static const TextStyle headlineLarge = TextStyle( + fontFamily: 'Figtree', + fontWeight: FontWeight.w800, + fontSize: 56, + color: GalleryColors.darkBlue, + ); + + static const TextStyle headlineMedium = TextStyle( + fontWeight: FontWeight.w700, + fontFamily: 'Figtree', + fontSize: 40, + color: GalleryColors.darkGrey, + ); + + static const TextStyle titleLarge = TextStyle( + fontFamily: 'Figtree', + fontSize: 32, + fontWeight: FontWeight.w600, + letterSpacing: -0.41, + color: GalleryColors.darkBlue, + ); + + static const TextStyle bodyLarge = TextStyle( + fontFamily: 'Figtree', + fontWeight: FontWeight.w700, + fontSize: 22, + color: GalleryColors.darkTeal, + ); + + static const TextStyle bodyMedium = TextStyle( + fontFamily: 'Figtree', + fontWeight: FontWeight.w500, + fontSize: 16, + letterSpacing: 0.08, + color: GalleryColors.grey, + ); +} diff --git a/packages/gyver_lamp_ui/gallery/lib/theme/theme.dart b/packages/gyver_lamp_ui/gallery/lib/theme/theme.dart new file mode 100644 index 0000000..634f136 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/lib/theme/theme.dart @@ -0,0 +1,2 @@ +export 'colors.dart'; +export 'text_styles.dart'; diff --git a/packages/gyver_lamp_ui/gallery/pubspec.yaml b/packages/gyver_lamp_ui/gallery/pubspec.yaml new file mode 100644 index 0000000..6f40b86 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/pubspec.yaml @@ -0,0 +1,32 @@ +name: gallery +description: Gallery project to showcase gyver_lamp_ui. +version: 1.0.0 +publish_to: 'none' + +environment: + sdk: '>=3.0.5 <4.0.0' + +dependencies: + dashbook: 0.1.12 + flutter: + sdk: flutter + gyver_lamp_icons: + path: ../../gyver_lamp_icons + gyver_lamp_ui: + path: ../ + +dev_dependencies: + very_good_analysis: 4.0.0 + +flutter: + uses-material-design: true + + assets: + - assets/images/button.png + - assets/images/palette.png + - assets/images/typography.png + + fonts: + - family: Figtree + fonts: + - asset: assets/fonts/Figtree-Regular.ttf \ No newline at end of file diff --git a/packages/gyver_lamp_ui/gallery/web/favicon.png b/packages/gyver_lamp_ui/gallery/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/packages/gyver_lamp_ui/gallery/web/favicon.png differ diff --git a/packages/gyver_lamp_ui/gallery/web/icons/Icon-192.png b/packages/gyver_lamp_ui/gallery/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/packages/gyver_lamp_ui/gallery/web/icons/Icon-192.png differ diff --git a/packages/gyver_lamp_ui/gallery/web/icons/Icon-512.png b/packages/gyver_lamp_ui/gallery/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/packages/gyver_lamp_ui/gallery/web/icons/Icon-512.png differ diff --git a/packages/gyver_lamp_ui/gallery/web/icons/Icon-maskable-192.png b/packages/gyver_lamp_ui/gallery/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/packages/gyver_lamp_ui/gallery/web/icons/Icon-maskable-192.png differ diff --git a/packages/gyver_lamp_ui/gallery/web/icons/Icon-maskable-512.png b/packages/gyver_lamp_ui/gallery/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/packages/gyver_lamp_ui/gallery/web/icons/Icon-maskable-512.png differ diff --git a/packages/gyver_lamp_ui/gallery/web/index.html b/packages/gyver_lamp_ui/gallery/web/index.html new file mode 100644 index 0000000..332b4b2 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/web/index.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + gallery + + + + + + + + + + diff --git a/packages/gyver_lamp_ui/gallery/web/manifest.json b/packages/gyver_lamp_ui/gallery/web/manifest.json new file mode 100644 index 0000000..cf706d7 --- /dev/null +++ b/packages/gyver_lamp_ui/gallery/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "gallery", + "short_name": "gallery", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/packages/gyver_lamp_ui/lib/gyver_lamp_ui.dart b/packages/gyver_lamp_ui/lib/gyver_lamp_ui.dart new file mode 100644 index 0000000..882a7f0 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/gyver_lamp_ui.dart @@ -0,0 +1,4 @@ +export 'src/extensions/extensions.dart'; +export 'src/navigation/navigation.dart'; +export 'src/theme/theme.dart'; +export 'src/widgets/widgets.dart'; diff --git a/packages/gyver_lamp_ui/lib/src/extensions/extensions.dart b/packages/gyver_lamp_ui/lib/src/extensions/extensions.dart new file mode 100644 index 0000000..e2c3b1a --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/extensions/extensions.dart @@ -0,0 +1 @@ +export 'intersperse.dart'; diff --git a/packages/gyver_lamp_ui/lib/src/extensions/intersperse.dart b/packages/gyver_lamp_ui/lib/src/extensions/intersperse.dart new file mode 100644 index 0000000..8386e9c --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/extensions/intersperse.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +/// Extension on [Iterable] of widgets to insert `element` between each pair. +extension ChildrenIntersperse on Iterable { + /// Inserts `element` between each pair of [Iterable] elements. + Iterable intersperse(Widget element) sync* { + final iterator = this.iterator; + if (iterator.moveNext()) { + yield iterator.current; + while (iterator.moveNext()) { + yield element; + yield iterator.current; + } + } + } +} diff --git a/packages/gyver_lamp_ui/lib/src/navigation/gyver_lamp_page_route.dart b/packages/gyver_lamp_ui/lib/src/navigation/gyver_lamp_page_route.dart new file mode 100644 index 0000000..da54894 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/navigation/gyver_lamp_page_route.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +/// {@template gyver_lamp_page_route} +/// Default [MaterialPageRoute] for the Gyver Lamp application. +/// {@endtemplate} +class GyverLampPageRoute extends MaterialPageRoute { + /// {@macro gyver_lamp_page_route} + GyverLampPageRoute({ + required super.builder, + super.settings, + super.maintainState, + super.fullscreenDialog, + }); +} diff --git a/packages/gyver_lamp_ui/lib/src/navigation/navigation.dart b/packages/gyver_lamp_ui/lib/src/navigation/navigation.dart new file mode 100644 index 0000000..40de1f5 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/navigation/navigation.dart @@ -0,0 +1 @@ +export 'gyver_lamp_page_route.dart'; diff --git a/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_colors.dart b/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_colors.dart new file mode 100644 index 0000000..8ca3854 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_colors.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; + +/// Colors used in the Gyver Lamp UI +abstract class GyverLampColors { + /// Light background + static const Color lightBackground = Color(0xFFF4F7FD); + + /// Light on-background + static const Color lightOnBackground = Color(0xFF25323F); + + /// Light surface-primary + static const Color lightSurfacePrimary = Colors.white; + + /// Light surface-secondary + static const Color lightSurfaceSecondary = Color(0xFFE9F0FA); + + /// Light Surface Variant + static const Color lightSurfaceVariant = Color(0xFFEFEFF1); + + /// Light border-primary + static const Color lightBorderPrimary = Color(0xFFEFEFF1); + + /// Light border-input + static const Color lightBorderInput = Color(0xFF9EA4AA); + + /// Light text-primary + static const Color lightTextPrimary = Color(0xFF142230); + + /// Light text-secondary + static const Color lightTextSecondary = Color(0xFF868D95); + + /// Light pointer + static const Color lightPointer = Color(0xFF2265CB); + + /// Light connected-background + static const Color lightConnectedBackground = Color(0xFFCCFFD4); + + /// Light connected-text + static const Color lightConnectedText = Color(0xFF256A27); + + /// Light connecting-background + static const Color lightConnectingBackground = Color(0xFFFFFBDD); + + /// Light connecting-text + static const Color lightConnectingText = Color(0xFFC3B957); + + /// Light not-connected-background + static const Color lightNotConnectedBackground = Color(0xFFFAE9E9); + + /// Light not-connected-text + static const Color lightNotConnectedText = Color(0xFFD43838); + + /// Light divider + static const Color lightDivider = Color(0xFFEFF0F1); + + /// Light button disabled + static const Color lightButtonDisabled = Color(0xFFCBD0D7); + + /// Light text button disabled + static const Color lightTextButtonDisabled = Color(0xFFF4F7FD); + + /// Light selection background + static const Color lightSelectionBackground = Color(0xFFD3E0F5); + + /// Light selection handle + static const Color lightSelectionHandle = Color(0xFF2265CB); + + /// Light shadow 1 + static const Color lightShadow1 = Color(0x0C25323F); + + /// Light shadow 2 + static const Color lightShadow2 = Color(0x3325323F); + + /// Light shadow 3 + static const Color lightShadow3 = Color(0x3F25323F); + + /// Light shadow 4 + static const Color lightShadow4 = Color(0x4C25323F); + + /// Dark background + static const Color darkBackground = Color(0xFF142230); + + /// Dark on-background + static const Color darkOnBackground = Color(0xFFF4F7FD); + + /// Dark surface-primary + static const Color darkSurfacePrimary = Color(0xFF3D4955); + + /// Dark surface-secondary + static const Color darkSurfaceSecondary = Color(0xFF565F6A); + + /// Dark Surface Variant + static const Color darkSurfaceVariant = Color(0xFF6E767F); + + /// Dark border-primary + static const Color darkBorderPrimary = Color(0xFF3D4955); + + /// Dark border-input + static const Color darkBorderInput = Color(0xFF6E767F); + + /// Dark text-primary + static const Color darkTextPrimary = Color(0xFFF4F7FD); + + /// Dark text-secondary + static const Color darkTextSecondary = Color(0xFF9EA4AA); + + /// Dark pointer + static const Color darkPointer = Color(0xFF2265CB); + + /// Dark connected-background + static const Color darkConnectedBackground = Color(0xFFB3FFBF); + + /// Dark connected-text + static const Color darkConnectedText = Color(0xFF145017); + + /// Dark connecting-background + static const Color darkConnectingBackground = Color(0xFFF7F0B8); + + /// Dark connecting-text + static const Color darkConnectingText = Color(0xFFAA9B0F); + + /// Dark not-connected-background + static const Color darkNotConnectedBackground = Color(0xFFF1BDBD); + + /// Dark not-connected-text + static const Color darkNotConnectedText = Color(0xFFCF2222); + + /// Dark divider + static const Color darkDivider = Color(0xFF565F6A); + + /// Dark button disabled + static const Color darkButtonDisabled = Color(0xFF424D5A); + + /// Dark text button disabled + static const Color darkTextButtonDisabled = Color(0xFF152231); + + /// Dark selection background + static const Color darkSelectionBackground = Color(0xFF25323F); + + /// Dark selection handle + static const Color darkSelectionHandle = Color(0xFF2265CB); + + /// Dark shadow 1 + static const Color darkShadow1 = Color(0x0C000000); + + /// Dark shadow 2 + static const Color darkShadow2 = Color(0x33000000); + + /// Dark shadow 3 + static const Color darkShadow3 = Color(0x3F000000); + + /// Dark shadow 4 + static const Color darkShadow4 = Color(0x4C000000); +} diff --git a/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_shadows.dart b/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_shadows.dart new file mode 100644 index 0000000..b7c8543 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_shadows.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/src/theme/theme.dart'; + +/// Shadows used in the Gyver Lamp UI. +class GyverLampShadows { + const GyverLampShadows._({ + required this.shadow1, + required this.shadow2, + required this.shadow3, + required this.shadow4, + }); + + /// Shadow 1 + final BoxShadow shadow1; + + /// Shadow 2 + final BoxShadow shadow2; + + /// Shadow 3 + final BoxShadow shadow3; + + /// Shadow 4 + final BoxShadow shadow4; + + /// Shadows for the light theme. + static const light = GyverLampShadows._( + shadow1: BoxShadow( + color: GyverLampColors.lightShadow1, + blurRadius: 8, + offset: Offset(0, 1), + ), + shadow2: BoxShadow( + color: GyverLampColors.lightShadow2, + blurRadius: 12, + offset: Offset(0, 2), + ), + shadow3: BoxShadow( + color: GyverLampColors.lightShadow3, + blurRadius: 16, + offset: Offset(0, 2), + ), + shadow4: BoxShadow( + color: GyverLampColors.lightShadow4, + blurRadius: 48, + offset: Offset(0, 15), + ), + ); + + /// Shadows for the dark theme. + static const dark = GyverLampShadows._( + shadow1: BoxShadow( + color: GyverLampColors.darkShadow1, + blurRadius: 8, + offset: Offset(0, 1), + ), + shadow2: BoxShadow( + color: GyverLampColors.darkShadow2, + blurRadius: 12, + offset: Offset(0, 2), + ), + shadow3: BoxShadow( + color: GyverLampColors.darkShadow3, + blurRadius: 16, + offset: Offset(0, 2), + ), + shadow4: BoxShadow( + color: GyverLampColors.darkShadow4, + blurRadius: 48, + offset: Offset(0, 15), + ), + ); + + /// Linearly interpolate between two shadows. + // ignore: prefer_constructors_over_static_methods + static GyverLampShadows lerp( + GyverLampShadows a, + GyverLampShadows b, + double t, + ) { + return GyverLampShadows._( + shadow1: BoxShadow.lerp(a.shadow1, b.shadow1, t)!, + shadow2: BoxShadow.lerp(a.shadow2, b.shadow2, t)!, + shadow3: BoxShadow.lerp(a.shadow3, b.shadow3, t)!, + shadow4: BoxShadow.lerp(a.shadow4, b.shadow4, t)!, + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_spacings.dart b/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_spacings.dart new file mode 100644 index 0000000..8f6bd47 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_spacings.dart @@ -0,0 +1,35 @@ +/// Spacings used in the Gyver Lamp UI. +abstract class GyverLampSpacings { + /// The default unit of spacing + static const double spaceUnit = 16; + + /// xxxs spacing value (1pt) + static const double xxxs = 0.0625 * spaceUnit; + + /// xxs spacing value (2pt) + static const double xxs = 0.125 * spaceUnit; + + /// xs spacing value (4pt) + static const double xs = 0.25 * spaceUnit; + + /// sm spacing value (8pt) + static const double sm = 0.5 * spaceUnit; + + /// md spacing value (12pt) + static const double md = 0.75 * spaceUnit; + + /// lg spacing value (16pt) + static const double lg = spaceUnit; + + /// smxlg spacing value (20pt) + static const double xlgsm = 1.25 * spaceUnit; + + /// xlg spacing value (24pt) + static const double xlg = 1.5 * spaceUnit; + + /// xxlg spacing value (40pt) + static const double xxlg = 2.5 * spaceUnit; + + /// xxxlg pacing value (64pt) + static const double xxxlg = 4 * spaceUnit; +} diff --git a/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_text_styles.dart b/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_text_styles.dart new file mode 100644 index 0000000..51ad16a --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_text_styles.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; + +/// Text styles used in the Gyver Lamp UI. +abstract class GyverLampTextStyles { + /// Package name + static const package = 'gyver_lamp_ui'; + + /// Font family name + static const fontFamily = 'Inter'; + + /// Creates a [TextTheme] from the text styles. + static TextTheme get textTheme => const TextTheme( + labelLarge: buttonLarge, + titleMedium: subtitle1, + titleSmall: subtitle2, + labelSmall: overline, + bodyLarge: body1, + bodyMedium: body2, + bodySmall: caption, + ); + + /// headline4 + static const TextStyle headline4 = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 36, + letterSpacing: 0.09, + package: package, + ); + + /// headline4Bold + static const TextStyle headline4Bold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w500, + fontSize: 36, + letterSpacing: 0.09, + package: package, + ); + + /// headline5 + static const TextStyle headline5 = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 26, + package: package, + ); + + /// headline5Bold + static const TextStyle headline5Bold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 26, + package: package, + ); + + /// headline6 + static const TextStyle headline6 = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 21, + letterSpacing: 0.03, + package: package, + ); + + /// headline6Bold + static const TextStyle headline6Bold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 21, + letterSpacing: 0.03, + package: package, + ); + + /// subtitle1 + static const TextStyle subtitle1 = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 17, + letterSpacing: 0.03, + package: package, + ); + + /// subtitle1Bold + static const TextStyle subtitle1Bold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 17, + letterSpacing: 0.03, + package: package, + ); + + /// subtitle2 + static const TextStyle subtitle2 = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 15, + letterSpacing: 0.02, + package: package, + ); + + /// subtitle2Bold + static const TextStyle subtitle2Bold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 15, + letterSpacing: 0.02, + package: package, + ); + + /// body1 + static const TextStyle body1 = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 17, + letterSpacing: 0.09, + package: package, + ); + + /// body1Bold + static const TextStyle body1Bold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 17, + letterSpacing: 0.09, + package: package, + ); + + /// body2 + static const TextStyle body2 = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 15, + height: 1.26, + letterSpacing: 0.25, + package: package, + ); + + /// body2Bold + static const TextStyle body2Bold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 15, + height: 1.26, + letterSpacing: 0.25, + package: package, + ); + + /// buttonLarge + static const TextStyle buttonLarge = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 17, + height: 1.41, + letterSpacing: 0.21, + package: package, + ); + + /// buttonLargeBold + static const TextStyle buttonLargeBold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 17, + height: 1.41, + letterSpacing: 0.21, + package: package, + ); + + /// buttonMedium + static const TextStyle buttonMedium = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 14, + height: 1.14, + letterSpacing: 0.17, + package: package, + ); + + /// buttonMediumBold + static const TextStyle buttonMediumBold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 14, + height: 1.14, + letterSpacing: 0.17, + package: package, + ); + + /// buttonSmall + static const TextStyle buttonSmall = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 12, + height: 1.33, + letterSpacing: 0.15, + package: package, + ); + + /// buttonSmallBold + static const TextStyle buttonSmallBold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 12, + height: 1.33, + letterSpacing: 0.15, + package: package, + ); + + /// caption + static const TextStyle caption = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 13, + letterSpacing: 0.05, + package: package, + ); + + /// captionBold + static const TextStyle captionBold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 13, + letterSpacing: 0.05, + package: package, + ); + + /// overline + static const TextStyle overline = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w400, + fontSize: 11, + letterSpacing: 0.17, + package: package, + ); + + /// overlineBold + static const TextStyle overlineBold = TextStyle( + fontFamily: fontFamily, + fontWeight: FontWeight.w600, + fontSize: 11, + letterSpacing: 0.17, + package: package, + ); +} diff --git a/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_theme.dart b/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_theme.dart new file mode 100644 index 0000000..9b4a439 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/theme/gyver_lamp_theme.dart @@ -0,0 +1,450 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/src/theme/theme.dart'; + +/// {@template gyver_lamp_theme} +/// Gyver Lamp Theme. +/// {@endtemplate} +abstract class GyverLampTheme { + /// Light [ThemeData] for Gyver Lamp. + static ThemeData get lightThemeData { + return ThemeData( + useMaterial3: false, + colorScheme: const ColorScheme.light().copyWith( + surface: GyverLampColors.lightSurfacePrimary, + background: GyverLampColors.lightBackground, + ), + scaffoldBackgroundColor: GyverLampColors.lightBackground, + textTheme: GyverLampTextStyles.textTheme, + inputDecorationTheme: InputDecorationTheme( + border: MaterialStateOutlineInputBorder.resolveWith( + (states) { + if (states.contains(MaterialState.focused)) { + return const OutlineInputBorder( + borderSide: BorderSide( + color: GyverLampColors.lightBorderInput, + ), + borderRadius: BorderRadius.all(Radius.circular(8)), + ); + } + + return const OutlineInputBorder( + borderSide: BorderSide( + width: 0.5, + color: GyverLampColors.lightBorderPrimary, + ), + borderRadius: BorderRadius.all(Radius.circular(8)), + ); + }, + ), + hoverColor: Colors.transparent, + constraints: const BoxConstraints.tightFor(height: 42), + contentPadding: const EdgeInsets.symmetric( + horizontal: GyverLampSpacings.md, + ), + filled: true, + fillColor: GyverLampColors.lightSurfacePrimary, + hintStyle: GyverLampTextStyles.body2.copyWith( + color: GyverLampColors.lightTextSecondary.withOpacity(0.5), + ), + suffixIconColor: GyverLampColors.lightTextSecondary, + ), + dividerTheme: const DividerThemeData( + color: GyverLampColors.lightDivider, + thickness: 2, + space: 2, + ), + textSelectionTheme: const TextSelectionThemeData( + selectionColor: GyverLampColors.lightSelectionBackground, + selectionHandleColor: GyverLampColors.lightSelectionHandle, + cursorColor: GyverLampColors.lightTextPrimary, + ), + cupertinoOverrideTheme: const CupertinoThemeData( + // this color will be used as selectionHandleColor on iOS + primaryColor: GyverLampColors.lightSelectionHandle, + ), + extensions: const [ + GyverLampAppTheme( + background: GyverLampColors.lightBackground, + onBackground: GyverLampColors.lightOnBackground, + surfacePrimary: GyverLampColors.lightSurfacePrimary, + surfaceSecondary: GyverLampColors.lightSurfaceSecondary, + surfaceVariant: GyverLampColors.lightSurfaceVariant, + borderPrimary: GyverLampColors.lightBorderPrimary, + borderInput: GyverLampColors.lightBorderInput, + textPrimary: GyverLampColors.lightTextPrimary, + textSecondary: GyverLampColors.lightTextSecondary, + pointer: GyverLampColors.lightPointer, + connectedBackground: GyverLampColors.lightConnectedBackground, + connectedText: GyverLampColors.lightConnectedText, + connectingBackground: GyverLampColors.lightConnectingBackground, + connectingText: GyverLampColors.lightConnectingText, + notConnectedBackground: GyverLampColors.lightNotConnectedBackground, + notConnectedText: GyverLampColors.lightNotConnectedText, + divider: GyverLampColors.lightDivider, + buttonDisabled: GyverLampColors.lightButtonDisabled, + textButtonDisabled: GyverLampColors.lightTextButtonDisabled, + selectionBackground: GyverLampColors.lightSelectionBackground, + selectionHandle: GyverLampColors.lightSelectionHandle, + shadows: GyverLampShadows.light, + ), + ], + ); + } + + /// Dark [ThemeData] for Gyver Lamp. + static ThemeData get darkThemeData { + return ThemeData( + useMaterial3: false, + colorScheme: const ColorScheme.dark().copyWith( + surface: GyverLampColors.darkSurfacePrimary, + background: GyverLampColors.darkBackground, + ), + scaffoldBackgroundColor: GyverLampColors.darkBackground, + textTheme: GyverLampTextStyles.textTheme, + inputDecorationTheme: InputDecorationTheme( + border: MaterialStateOutlineInputBorder.resolveWith( + (states) { + if (states.contains(MaterialState.focused)) { + return const OutlineInputBorder( + borderSide: BorderSide( + color: GyverLampColors.darkBorderInput, + ), + borderRadius: BorderRadius.all(Radius.circular(8)), + ); + } + + return const OutlineInputBorder( + borderSide: BorderSide( + width: 0.5, + color: GyverLampColors.darkBorderPrimary, + ), + borderRadius: BorderRadius.all(Radius.circular(8)), + ); + }, + ), + hoverColor: Colors.transparent, + constraints: const BoxConstraints.tightFor(height: 42), + contentPadding: const EdgeInsets.symmetric( + horizontal: GyverLampSpacings.md, + ), + filled: true, + fillColor: GyverLampColors.darkSurfacePrimary, + hintStyle: GyverLampTextStyles.body2.copyWith( + color: GyverLampColors.darkTextSecondary.withOpacity(0.5), + ), + suffixIconColor: GyverLampColors.darkTextSecondary, + ), + dividerTheme: const DividerThemeData( + color: GyverLampColors.darkDivider, + thickness: 1, + space: 1, + ), + textSelectionTheme: const TextSelectionThemeData( + selectionColor: GyverLampColors.darkSelectionBackground, + selectionHandleColor: GyverLampColors.darkSelectionHandle, + cursorColor: GyverLampColors.darkTextPrimary, + ), + cupertinoOverrideTheme: const CupertinoThemeData( + // this color will be used as selectionHandleColor on iOS + primaryColor: GyverLampColors.darkSelectionHandle, + ), + extensions: const [ + GyverLampAppTheme( + background: GyverLampColors.darkBackground, + onBackground: GyverLampColors.darkOnBackground, + surfacePrimary: GyverLampColors.darkSurfacePrimary, + surfaceSecondary: GyverLampColors.darkSurfaceSecondary, + surfaceVariant: GyverLampColors.darkSurfaceVariant, + borderPrimary: GyverLampColors.darkBorderPrimary, + borderInput: GyverLampColors.darkBorderInput, + textPrimary: GyverLampColors.darkTextPrimary, + textSecondary: GyverLampColors.darkTextSecondary, + pointer: GyverLampColors.darkPointer, + connectedBackground: GyverLampColors.darkConnectedBackground, + connectedText: GyverLampColors.darkConnectedText, + connectingBackground: GyverLampColors.darkConnectingBackground, + connectingText: GyverLampColors.darkConnectingText, + notConnectedBackground: GyverLampColors.darkNotConnectedBackground, + notConnectedText: GyverLampColors.darkNotConnectedText, + divider: GyverLampColors.darkDivider, + buttonDisabled: GyverLampColors.darkButtonDisabled, + textButtonDisabled: GyverLampColors.darkTextButtonDisabled, + selectionBackground: GyverLampColors.darkSelectionBackground, + selectionHandle: GyverLampColors.darkSelectionHandle, + shadows: GyverLampShadows.dark, + ), + ], + ); + } +} + +/// {@template gyver_lamp_app_theme} +/// Theme extension to hold specific colors and shadows without brightness +/// mention. +/// {@endtemplate} +class GyverLampAppTheme extends ThemeExtension { + /// {@macro gyver_lamp_app_theme} + const GyverLampAppTheme({ + required this.background, + required this.onBackground, + required this.surfacePrimary, + required this.surfaceSecondary, + required this.surfaceVariant, + required this.borderPrimary, + required this.borderInput, + required this.textPrimary, + required this.textSecondary, + required this.pointer, + required this.connectedBackground, + required this.connectedText, + required this.connectingBackground, + required this.connectingText, + required this.notConnectedBackground, + required this.notConnectedText, + required this.divider, + required this.buttonDisabled, + required this.textButtonDisabled, + required this.selectionBackground, + required this.selectionHandle, + required this.shadows, + }); + + /// Background Color + final Color background; + + /// On Background Color + final Color onBackground; + + /// Surface Primary Color + final Color surfacePrimary; + + /// Surface Secondary Color + final Color surfaceSecondary; + + /// Surface Variant Color + final Color surfaceVariant; + + /// Border Primary Color + final Color borderPrimary; + + /// Border Input Color + final Color borderInput; + + /// Text Primary Color + final Color textPrimary; + + /// Text Secondary Color + final Color textSecondary; + + /// Pointer Color + final Color pointer; + + /// Connected Background Color + final Color connectedBackground; + + /// Connected Text Color + final Color connectedText; + + /// Connecting Background Color + final Color connectingBackground; + + /// Connecting Text Color + final Color connectingText; + + /// Not Connected Background Color + final Color notConnectedBackground; + + /// Not Connected Text Color + final Color notConnectedText; + + /// Divider Color + final Color divider; + + /// Button Disabled Color + final Color buttonDisabled; + + /// Text Button Disabled Color + final Color textButtonDisabled; + + /// Selection Background Color + final Color selectionBackground; + + /// Selection Handle Color + final Color selectionHandle; + + /// Shadows + final GyverLampShadows shadows; + + @override + GyverLampAppTheme copyWith({ + Color? background, + Color? onBackground, + Color? surfacePrimary, + Color? surfaceSecondary, + Color? surfaceVariant, + Color? borderPrimary, + Color? borderInput, + Color? textPrimary, + Color? textSecondary, + Color? pointer, + Color? connectedBackground, + Color? connectedText, + Color? connectingBackground, + Color? connectingText, + Color? notConnectedBackground, + Color? notConnectedText, + Color? divider, + Color? buttonDisabled, + Color? textButtonDisabled, + Color? selectionBackground, + Color? selectionHandle, + GyverLampShadows? shadows, + }) { + return GyverLampAppTheme( + background: background ?? this.background, + onBackground: onBackground ?? this.onBackground, + surfacePrimary: surfacePrimary ?? this.surfacePrimary, + surfaceSecondary: surfaceSecondary ?? this.surfaceSecondary, + surfaceVariant: surfaceVariant ?? this.surfaceVariant, + borderPrimary: borderPrimary ?? this.borderPrimary, + borderInput: borderInput ?? this.borderInput, + textPrimary: textPrimary ?? this.textPrimary, + textSecondary: textSecondary ?? this.textSecondary, + pointer: pointer ?? this.pointer, + connectedBackground: connectedBackground ?? this.connectedBackground, + connectedText: connectedText ?? this.connectedText, + connectingBackground: connectingBackground ?? this.connectingBackground, + connectingText: connectingText ?? this.connectingText, + notConnectedBackground: + notConnectedBackground ?? this.notConnectedBackground, + notConnectedText: notConnectedText ?? this.notConnectedText, + divider: divider ?? this.divider, + buttonDisabled: buttonDisabled ?? this.buttonDisabled, + textButtonDisabled: textButtonDisabled ?? this.textButtonDisabled, + selectionBackground: selectionBackground ?? this.selectionBackground, + selectionHandle: selectionHandle ?? this.selectionHandle, + shadows: shadows ?? this.shadows, + ); + } + + @override + GyverLampAppTheme lerp(GyverLampAppTheme? other, double t) { + if (other is! GyverLampAppTheme) { + return this; + } + + return GyverLampAppTheme( + background: Color.lerp( + background, + other.background, + t, + )!, + onBackground: Color.lerp( + onBackground, + other.onBackground, + t, + )!, + surfacePrimary: Color.lerp( + surfacePrimary, + other.surfacePrimary, + t, + )!, + surfaceSecondary: Color.lerp( + surfaceSecondary, + other.surfaceSecondary, + t, + )!, + surfaceVariant: Color.lerp( + surfaceVariant, + other.surfaceVariant, + t, + )!, + borderPrimary: Color.lerp( + borderPrimary, + other.borderPrimary, + t, + )!, + borderInput: Color.lerp( + borderInput, + other.borderInput, + t, + )!, + textPrimary: Color.lerp( + textPrimary, + other.textPrimary, + t, + )!, + textSecondary: Color.lerp( + textSecondary, + other.textSecondary, + t, + )!, + pointer: Color.lerp( + pointer, + other.pointer, + t, + )!, + connectedBackground: Color.lerp( + connectedBackground, + other.connectedBackground, + t, + )!, + connectedText: Color.lerp( + connectedText, + other.connectedText, + t, + )!, + connectingBackground: Color.lerp( + connectingBackground, + other.connectingBackground, + t, + )!, + connectingText: Color.lerp( + connectingText, + other.connectingText, + t, + )!, + notConnectedBackground: Color.lerp( + notConnectedBackground, + other.notConnectedBackground, + t, + )!, + notConnectedText: Color.lerp( + notConnectedText, + other.notConnectedText, + t, + )!, + divider: Color.lerp( + divider, + other.divider, + t, + )!, + buttonDisabled: Color.lerp( + buttonDisabled, + other.buttonDisabled, + t, + )!, + textButtonDisabled: Color.lerp( + textButtonDisabled, + other.textButtonDisabled, + t, + )!, + selectionBackground: Color.lerp( + selectionBackground, + other.selectionBackground, + t, + )!, + selectionHandle: Color.lerp( + selectionHandle, + other.selectionHandle, + t, + )!, + shadows: GyverLampShadows.lerp( + shadows, + other.shadows, + t, + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/theme/theme.dart b/packages/gyver_lamp_ui/lib/src/theme/theme.dart new file mode 100644 index 0000000..530e52c --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/theme/theme.dart @@ -0,0 +1,5 @@ +export 'gyver_lamp_colors.dart'; +export 'gyver_lamp_shadows.dart'; +export 'gyver_lamp_spacings.dart'; +export 'gyver_lamp_text_styles.dart'; +export 'gyver_lamp_theme.dart'; diff --git a/packages/gyver_lamp_ui/lib/src/widgets/alert_messenger.dart b/packages/gyver_lamp_ui/lib/src/widgets/alert_messenger.dart new file mode 100644 index 0000000..1d684e9 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/alert_messenger.dart @@ -0,0 +1,344 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +/// The error alert message display duration. +const kErrorAlertDuration = Duration(seconds: 10); + +/// The info alert message display duration. +const kInfoAlertDuration = Duration(seconds: 5); + +/// The alert message show animation duration. +const kShowDuration = Duration(milliseconds: 250); + +/// The alert message hide animation duration. +const kHideDuration = Duration(milliseconds: 150); + +/// {@template alert_messenger} +/// Gyver Lamp Alert Messenger. +/// +/// Typically [AlertMessenger] is used in the [MaterialApp] builder as follows: +/// ```dart +/// return MaterialApp( +/// home: const Scaffold(), +/// theme: GyverLampTheme.lightThemeData, +/// builder: (context, child) { +/// return AlertMessenger( +/// child: child!, +/// ); +/// }, +/// ); +/// ``` +/// {@endtemplate} +class AlertMessenger extends StatefulWidget { + /// {@macro alert_messenger} + const AlertMessenger({ + required this.child, + super.key, + }); + + /// The child widget. + final Widget child; + + /// The state from the closest instance of this class that encloses the given + /// context. + /// + /// Typical usage of the [AlertMessenger.of] function is to call it in + /// response to a user gesture or an application state change. + static AlertMessengerState of(BuildContext context) { + final state = context.findAncestorStateOfType(); + + // ignore: prefer_asserts_with_message + assert(() { + if (state == null) { + throw FlutterError.fromParts([ + ErrorSummary('No AlertMessenger widget found.'), + ErrorDescription( + '${context.widget.runtimeType} widgets require an AlertMessenger ' + 'widget ancestor.', + ), + ...context.describeMissingAncestor( + expectedAncestorType: AlertMessenger, + ), + ]); + } + + return true; + }()); + + return state!; + } + + @override + State createState() => AlertMessengerState(); +} + +/// State for a [AlertMessenger]. +class AlertMessengerState extends State + with SingleTickerProviderStateMixin { + final _overlayKey = GlobalKey(); + + final _entries = Queue(); + + late final OverlayEntry _childEntry; + + late final AnimationController _controller; + + Timer? _timer; + + @override + void initState() { + super.initState(); + + _childEntry = OverlayEntry( + builder: (context) => widget.child, + maintainState: true, + opaque: true, + ); + + _controller = AnimationController( + vsync: this, + duration: kShowDuration, + reverseDuration: kHideDuration, + ); + } + + @override + void dispose() { + for (final entry in _entries) { + entry + ..remove() + ..dispose(); + } + _entries.clear(); + + _childEntry + ..remove() + ..dispose(); + + _controller.dispose(); + _timer?.cancel(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + child: Overlay( + key: _overlayKey, + initialEntries: [_childEntry], + ), + ); + } + + /// Shows an error alert message with animation. + Future showError({ + required String message, + }) async { + final alert = _Alert.error( + message: message, + onClose: hide, + ); + + return _show(alert); + } + + /// Shows an info alert message with animation. + Future showInfo({ + required String message, + }) async { + final alert = _Alert.info( + message: message, + onClose: hide, + ); + + return _show(alert); + } + + Future _show(_Alert alert) async { + final entry = OverlayEntry( + builder: (context) => _buildOverlayEntry(alert), + ); + + _entries.addLast(entry); + + if (_timer != null) { + await hide(); + } + + _overlayKey.currentState!.insert(entry); + + _timer = Timer(alert.duration, hide); + + await _controller.forward(); + } + + /// Hides current alert message with animation. + Future hide() async { + if (_entries.isEmpty) { + return; + } + + await _controller.reverse(); + + _entries.removeFirst().remove(); + + _timer?.cancel(); + _timer = null; + } + + /// Hides current alert message without animation. + void clear() { + _timer?.cancel(); + _timer = null; + + for (final entry in _entries) { + entry.remove(); + } + + _entries.clear(); + } + + Widget _buildOverlayEntry(_Alert alert) { + return Align( + alignment: Alignment.topCenter, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final double dy; + final double opacity; + + if (_controller.status == AnimationStatus.forward) { + final curvedAnimation = Curves.easeOutBack.transform( + _controller.value, + ); + dy = (1 - curvedAnimation) * 10; + opacity = Curves.easeOutQuart.transform(_controller.value); + } else { + final curvedAnimation = Curves.easeIn.transform( + _controller.value, + ); + dy = (1 - curvedAnimation) * 5; + opacity = Curves.easeIn.transform(_controller.value); + } + + return Opacity( + opacity: opacity, + child: Transform.translate( + offset: Offset(0, dy), + child: child, + ), + ); + }, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.all(GyverLampSpacings.xlg), + child: alert, + ), + ), + ), + ); + } +} + +enum _AlertType { + error, + info, +} + +class _Alert extends StatelessWidget { + const _Alert.error({ + required this.message, + required this.onClose, + }) : type = _AlertType.error; + + const _Alert.info({ + required this.message, + required this.onClose, + }) : type = _AlertType.info; + + final _AlertType type; + + final String message; + + final VoidCallback onClose; + + Duration get duration => switch (type) { + _AlertType.error => kErrorAlertDuration, + _AlertType.info => kInfoAlertDuration, + }; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + final Color backgroundColor; + final Color textColor; + final Color dividerColor; + final Color borderColor; + + switch (type) { + case _AlertType.error: + backgroundColor = theme.notConnectedBackground; + textColor = theme.notConnectedText; + dividerColor = textColor.withOpacity(0.25); + borderColor = textColor.withOpacity(0.10); + + case _AlertType.info: + backgroundColor = theme.surfacePrimary; + textColor = theme.textPrimary; + dividerColor = theme.borderPrimary; + borderColor = theme.borderPrimary; + } + + return DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: const BorderRadius.all( + Radius.circular(10), + ), + border: Border.all(color: borderColor), + boxShadow: [theme.shadows.shadow2], + ), + child: Padding( + padding: const EdgeInsets.only( + left: GyverLampSpacings.lg, + right: GyverLampSpacings.sm, + top: GyverLampSpacings.sm, + bottom: GyverLampSpacings.sm, + ), + child: IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: Text( + message, + style: GyverLampTextStyles.body2.copyWith( + color: textColor, + ), + ), + ), + GyverLampGaps.md, + VerticalDivider( + width: 2, + thickness: 2, + color: dividerColor, + ), + GyverLampGaps.xs, + FlatIconButton.medium( + icon: GyverLampIcons.close, + color: backgroundColor, + onPressed: onClose, + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/circles_wave_loading_indicator.dart b/packages/gyver_lamp_ui/lib/src/widgets/circles_wave_loading_indicator.dart new file mode 100644 index 0000000..034d7a3 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/circles_wave_loading_indicator.dart @@ -0,0 +1,132 @@ +import 'dart:math' as math show pi, sin; + +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +/// {@template circles_wave_loading_indicator} +/// Gyver Lamp Circles Wave Loading Indicator. +/// {@endtemplate} +class CirclesWaveLoadingIndicator extends StatefulWidget { + /// {@macro circles_wave_loading_indicator} + const CirclesWaveLoadingIndicator({ + this.size = 10.0, + this.color = Colors.black, + this.duration = const Duration(seconds: 1), + super.key, + }); + + /// The size of the circle. + final double size; + + /// The color of the circle. + final Color? color; + + /// The duration of the one animation cycle. + final Duration duration; + + @override + State createState() => + _CirclesWaveLoadingIndicatorState(); +} + +class _CirclesWaveLoadingIndicatorState + extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + late final List> _animations; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + vsync: this, + duration: widget.duration, + )..repeat(); + + _animations = [ + _DelayedTween( + begin: -0.5, + end: 0.5, + delay: 0, + ).animate(_controller), + _DelayedTween( + begin: -0.5, + end: 0.5, + delay: 0.2, + ).animate(_controller), + _DelayedTween( + begin: -0.5, + end: 0.5, + delay: 0.4, + ).animate(_controller), + ]; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: SizedBox( + width: widget.size * 3 + widget.size, + height: widget.size * 3, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ..._animations.map( + (animation) { + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + return Transform.translate( + offset: Offset(0, widget.size * animation.value), + child: child, + ); + }, + child: SizedBox.square( + dimension: widget.size, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.color, + ), + ), + ), + ); + }, + ).intersperse( + SizedBox(width: widget.size / 2), + ), + ], + ), + ), + ), + ); + } +} + +class _DelayedTween extends Tween { + _DelayedTween({ + required this.delay, + super.begin, + super.end, + }); + + final double delay; + + @override + double lerp(double t) { + return super.lerp((math.sin((t - delay) * 2 * math.pi) + 1) / 2); + } + + @override + double evaluate(Animation animation) => lerp(animation.value); +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/confirmation_dialog.dart b/packages/gyver_lamp_ui/lib/src/widgets/confirmation_dialog.dart new file mode 100644 index 0000000..4a8785e --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/confirmation_dialog.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +/// {@template confirmation_dialog} +/// Gyver Lamp Confirmation Dialog. +/// {@endtemplate} +class ConfirmationDialog extends StatelessWidget { + /// {@macro confirmation_dialog} + const ConfirmationDialog({ + required this.title, + required this.body, + required this.onCancel, + required this.confirmLabel, + required this.cancelLabel, + required this.onConfirm, + super.key, + }); + + /// The title on top of the dialog. + final String title; + + /// The text in the body of the dialog. + final String body; + + /// The label of the cancel button. + final String cancelLabel; + + /// The label of the confirm button. + final String confirmLabel; + + /// Called when the cancel button tapped. + final VoidCallback onCancel; + + /// Called when the confirm button tapped. + final VoidCallback onConfirm; + + @override + Widget build(BuildContext context) { + return GyverLampDialog( + title: title, + body: Text(body), + actions: [ + RoundedOutlinedButton.medium( + onPressed: () { + Navigator.of(context).maybePop(); + onCancel(); + }, + child: Text(cancelLabel), + ), + RoundedElevatedButton.medium( + onPressed: () { + Navigator.of(context).maybePop(); + onConfirm(); + }, + child: Text(confirmLabel), + ), + ], + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/connection_status_badge.dart b/packages/gyver_lamp_ui/lib/src/widgets/connection_status_badge.dart new file mode 100644 index 0000000..3e44f72 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/connection_status_badge.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +const _kSizeAnimationDuration = Duration(milliseconds: 200); +const _kSwitchAnimationDuration = Duration(milliseconds: 200); + +// The size of the icon before the label. +const _kIconSize = 18.0; + +// The corner radius of the badge. +const _kBadgeRadius = Radius.circular(30); + +/// Signature for the function that returns a string representation of the +/// specific connection status. +typedef ConnectionStatusLabelResolver = String Function( + ConnectionStatus states, +); + +/// Represents the connection status. +enum ConnectionStatus { + /// Represents a connected status. + connected, + + /// Represents a connecting status. + connecting, + + /// Represents a not connected status. + notConnected, +} + +/// {@template connection_status_badge} +/// Gyver Lamp Connection Status Badge. +/// {@endtemplate} +class ConnectionStatusBadge extends StatefulWidget { + /// {@macro connection_status_badge} + const ConnectionStatusBadge({ + required this.status, + required this.label, + required this.onPressed, + super.key, + }); + + /// Current connection status. + final ConnectionStatus status; + + /// The function to resolve the label for the specific status. + final ConnectionStatusLabelResolver label; + + /// Called when the badge is tapped. + final VoidCallback? onPressed; + + @override + State createState() => ConnectionStatusBadgeState(); +} + +/// Gyver Lamp Connection Status Badge state. +class ConnectionStatusBadgeState extends State { + /// Whether the badge is pressed. + @visibleForTesting + bool isPressed = false; + + @override + void setState(VoidCallback fn) { + if (mounted) super.setState(fn); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + final textColor = switch (widget.status) { + (ConnectionStatus.connected) => theme.connectedText, + (ConnectionStatus.connecting) => theme.connectingText, + (ConnectionStatus.notConnected) => theme.notConnectedText, + }; + + return RepaintBoundary( + child: AnimatedContainer( + clipBehavior: Clip.antiAlias, + duration: const Duration(milliseconds: 100), + decoration: ShapeDecoration( + color: switch (widget.status) { + (ConnectionStatus.connected) => theme.connectedBackground, + (ConnectionStatus.connecting) => theme.connectingBackground, + (ConnectionStatus.notConnected) => theme.notConnectedBackground, + }, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(_kBadgeRadius), + ), + shadows: [ + if (widget.onPressed != null && !isPressed) theme.shadows.shadow1, + ], + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + splashFactory: InkRipple.splashFactory, + borderRadius: const BorderRadius.all(_kBadgeRadius), + mouseCursor: widget.onPressed == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + onTap: widget.onPressed, + onTapDown: widget.onPressed == null + ? null + : (_) => setState(() => isPressed = true), + onTapUp: widget.onPressed == null + ? null + : (_) => setState(() => isPressed = false), + onTapCancel: widget.onPressed == null + ? null + : () => setState(() => isPressed = false), + child: Padding( + padding: const EdgeInsets.only( + left: GyverLampSpacings.sm, + right: GyverLampSpacings.lg, + top: GyverLampSpacings.sm, + bottom: GyverLampSpacings.sm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSwitcher( + duration: _kSwitchAnimationDuration, + child: Icon( + GyverLampIcons.wifi, + key: ValueKey(textColor), + color: textColor, + size: _kIconSize, + ), + ), + const SizedBox(width: GyverLampSpacings.sm), + Flexible( + child: AnimatedSize( + duration: _kSizeAnimationDuration, + curve: Curves.easeOut, + alignment: Alignment.centerLeft, + clipBehavior: Clip.antiAlias, + child: AnimatedSwitcher( + duration: _kSwitchAnimationDuration, + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: (child, animation) { + final isNewStatus = + child.key == ValueKey(widget.status); + + final yOffset = isNewStatus ? 0.5 : -0.5; + + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: Offset(0, yOffset), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + layoutBuilder: ( + Widget? currentChild, + List previousChildren, + ) { + final Widget? previousChild; + + if (previousChildren.isEmpty) { + previousChild = null; + } else { + previousChild = previousChildren.first; + } + + return Stack( + clipBehavior: Clip.antiAlias, + alignment: Alignment.centerLeft, + children: [ + if (previousChild != null) + Positioned.fill( + child: OverflowBox( + alignment: Alignment.centerLeft, + maxWidth: double.infinity, + child: previousChild, + ), + ), + if (currentChild != null) currentChild, + ], + ); + }, + child: Text( + widget.label(widget.status), + key: ValueKey(widget.status), + style: GyverLampTextStyles.buttonMediumBold.copyWith( + color: textColor, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/custom_app_bar.dart b/packages/gyver_lamp_ui/lib/src/widgets/custom_app_bar.dart new file mode 100644 index 0000000..02add87 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/custom_app_bar.dart @@ -0,0 +1,225 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +/// The preferred size of the custom app bar. +const kCustomAppBarSize = Size.fromHeight(GyverLampSpacings.xxxlg); + +/// {@template custom_app_bar} +/// Gyver Lamp Custom App Bar. +/// {@endtemplate} +class CustomAppBar extends StatefulWidget implements PreferredSizeWidget { + /// {@macro custom_app_bar} + const CustomAppBar({ + this.leading, + this.title, + this.actions, + this.padding = const EdgeInsets.symmetric( + horizontal: GyverLampSpacings.xs, + ), + super.key, + }); + + /// The widget to show before title. + final Widget? leading; + + /// The text of the title in the center of the app bar. + final String? title; + + /// The list of widgets to show after the title. + final List? actions; + + /// The padding around app bar components. + final EdgeInsets padding; + + @override + Size get preferredSize => kCustomAppBarSize; + + @override + State createState() => CustomAppBarState(); +} + +/// Gyver Lamp Custom App Bar state. +class CustomAppBarState extends State { + /// Whether the list content is scrolled under the app bar. + bool isScrolledUnder = false; + + ScrollNotificationObserverState? _scrollNotificationObserver; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _scrollNotificationObserver?.removeListener(_handleScrollNotification); + _scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context); + _scrollNotificationObserver?.addListener(_handleScrollNotification); + } + + @override + void dispose() { + if (_scrollNotificationObserver != null) { + _scrollNotificationObserver!.removeListener(_handleScrollNotification); + _scrollNotificationObserver = null; + } + + super.dispose(); + } + + void _handleScrollNotification(ScrollNotification notification) { + if (notification is ScrollUpdateNotification && notification.depth == 0) { + final oldIsScrolledUnder = isScrolledUnder; + final metrics = notification.metrics; + + switch (metrics.axisDirection) { + case AxisDirection.up: + // Scroll view is reversed + isScrolledUnder = metrics.extentAfter > 0; + + case AxisDirection.down: + isScrolledUnder = metrics.extentBefore > 0; + + case AxisDirection.right: + case AxisDirection.left: + // Scrolled under is only supported in the vertical axis, and should + // not be altered based on horizontal notifications of the same + // predicate since it could be a 2D scroller. + } + + if (isScrolledUnder != oldIsScrolledUnder) { + setState(() {}); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return DecoratedBox( + decoration: BoxDecoration( + color: theme.background, + boxShadow: isScrolledUnder ? [theme.shadows.shadow1] : null, + ), + child: SafeArea( + bottom: false, + child: ConstrainedBox( + constraints: BoxConstraints.tightFor( + height: widget.preferredSize.height, + ), + child: Padding( + padding: widget.padding, + child: CustomMultiChildLayout( + delegate: _CustomAppBarLayout(), + children: [ + if (widget.leading != null) + LayoutId( + id: _CustomAppBarSlot.leading, + child: widget.leading!, + ), + if (widget.title != null) + LayoutId( + id: _CustomAppBarSlot.title, + child: Text( + widget.title!, + style: GyverLampTextStyles.subtitle1.copyWith( + color: theme.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.actions != null) + LayoutId( + id: _CustomAppBarSlot.actions, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: widget.actions! + .intersperse(GyverLampGaps.xs) + .toList(), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +enum _CustomAppBarSlot { + leading, + title, + actions, +} + +/// Layout delegate that positions 3 widgets along a horizontal axis in order to +/// keep the title widget centered and leading and actions in the left and +/// right side of the screen respectively. +class _CustomAppBarLayout extends MultiChildLayoutDelegate { + /// The default spacing around the middle widget. + static const kTitleSpacing = GyverLampSpacings.sm; + + @override + void performLayout(Size size) { + var leadingWidth = 0.0; + var actionsWidth = 0.0; + + if (hasChild(_CustomAppBarSlot.leading)) { + final constraints = BoxConstraints.loose(size); + final leadingSize = layoutChild(_CustomAppBarSlot.leading, constraints); + + const leadingX = 0.0; + final leadingY = (size.height - leadingSize.height) / 2; + + leadingWidth = leadingSize.width; + + positionChild( + _CustomAppBarSlot.leading, + Offset(leadingX, leadingY), + ); + } + + if (hasChild(_CustomAppBarSlot.actions)) { + final constraints = BoxConstraints.loose(size); + final actionsSize = layoutChild(_CustomAppBarSlot.actions, constraints); + + final actionsX = size.width - actionsSize.width; + final actionsY = (size.height - actionsSize.height) / 2; + + actionsWidth = actionsSize.width; + + positionChild( + _CustomAppBarSlot.actions, + Offset(actionsX, actionsY), + ); + } + + if (hasChild(_CustomAppBarSlot.title)) { + final side = math.max(leadingWidth, actionsWidth); + final maxWidth = math.max( + size.width - side * 2 - kTitleSpacing * 2.0, + 0, + ); + + final constraints = BoxConstraints.loose(size).copyWith( + maxWidth: maxWidth, + ); + final titleSize = layoutChild(_CustomAppBarSlot.title, constraints); + + final titleX = (size.width - titleSize.width) / 2.0; + final titleY = (size.height - titleSize.height) / 2; + + positionChild( + _CustomAppBarSlot.title, + Offset(titleX, titleY), + ); + } + } + + @override + bool shouldRelayout(_CustomAppBarLayout oldDelegate) { + return false; + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/custom_dropdown_button.dart b/packages/gyver_lamp_ui/lib/src/widgets/custom_dropdown_button.dart new file mode 100644 index 0000000..401b62f --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/custom_dropdown_button.dart @@ -0,0 +1,896 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' hide DropdownButton, DropdownMenuItem; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +// The minimal height of menu item. +const _kMenuItemHeight = 48.0; + +// The height of divider between menu items. +const _kMenuDividerHeight = 2.0; + +// The max height of the menu item including divider. +const _kMaxMenuItemHeight = _kMenuItemHeight + _kMenuDividerHeight; + +// The size of the icons. +const _kIconSize = 24.0; + +// The default corner radius of dropdown elements. +const _kBorderRadius = BorderRadius.all(Radius.circular(8)); + +// The distance from bottom of the button to the menu. +const _kDropdownOffset = GyverLampSpacings.xs; + +// The space around menu. +const _kDropdownPadding = GyverLampSpacings.sm; + +/// {@template custom_dropdown_menu_item} +/// An item in a menu created by [CustomDropdownButton]. +/// {@endtemplate} +class CustomDropdownMenuItem { + /// {@macro custom_dropdown_menu_item} + const CustomDropdownMenuItem({ + required this.value, + required this.label, + }); + + /// The value of this item. + final T value; + + /// The string representation of the value. + final String label; +} + +/// {@template custom_dropdown_button} +/// Gyver Lamp Custom Dropdown Button. +/// {@endtemplate} +class CustomDropdownButton extends StatefulWidget { + /// {@macro custom_dropdown_button} + CustomDropdownButton({ + required this.items, + required this.selected, + required this.onChanged, + this.menuMaxHeight, + super.key, + }) : assert( + items.isEmpty || items.where((i) => i.value == selected).length == 1, + 'There should be exactly one item with value: $selected.\n' + 'Either zero or 2 or more [DropdownMenuItem]s were detected ' + 'with the same value.', + ); + + /// The list of items the user can select. + final List> items; + + /// The selected item. + final T selected; + + /// Called when the user selects an item. + final ValueChanged onChanged; + + /// The maximum height of the menu. + final double? menuMaxHeight; + + @override + State> createState() => + _CustomDropdownButtonState(); +} + +class _CustomDropdownButtonState + extends State> with WidgetsBindingObserver { + final ValueNotifier _isOpen = ValueNotifier(false); + + late int _selectedIndex; + + _CustomDropdownRoute? _dropdownRoute; + + Orientation? _lastOrientation; + + @override + void initState() { + super.initState(); + _updateSelectedIndex(); + } + + @override + void didUpdateWidget(CustomDropdownButton oldWidget) { + super.didUpdateWidget(oldWidget); + _updateSelectedIndex(); + } + + @override + void dispose() { + _removeDropdownRoute(); + + WidgetsBinding.instance.removeObserver(this); + + _isOpen.dispose(); + + super.dispose(); + } + + void _updateSelectedIndex() { + // coverage:ignore-start + assert( + widget.items.where((item) => item.value == widget.selected).length == 1, + 'There should be exactly one item with value: ${widget.selected}.', + ); + // coverage:ignore-end + + for (var itemIndex = 0; itemIndex < widget.items.length; itemIndex++) { + if (widget.items[itemIndex].value == widget.selected) { + _selectedIndex = itemIndex; + return; + } + } + } + + void _removeDropdownRoute() { + _dropdownRoute?._dismiss(); + _dropdownRoute = null; + _lastOrientation = null; + } + + void _onTap() { + final navigator = Navigator.of(context); + + assert( + _dropdownRoute == null, + 'Dropdown menu is already shown. Close it before showing another one.', + ); + + final buttonBox = context.findRenderObject()! as RenderBox; + final navigatorBox = navigator.context.findRenderObject()! as RenderBox; + + final localOffset = navigatorBox.globalToLocal( + buttonBox.localToGlobal(Offset.zero), + ); + final buttonLocalRect = localOffset & buttonBox.size; + + _dropdownRoute = _CustomDropdownRoute( + items: widget.items, + buttonRect: buttonLocalRect, + selectedIndex: _selectedIndex, + capturedThemes: InheritedTheme.capture( + from: context, + to: navigator.context, + ), + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + menuMaxHeight: widget.menuMaxHeight, + ); + + _isOpen.value = true; + + navigator.push(_dropdownRoute!).then( + (T? result) { + _isOpen.value = false; + + _removeDropdownRoute(); + + if (!mounted || result == null || result == widget.selected) { + return; + } + + widget.onChanged.call(result); + }, + ); + } + + @override + Widget build(BuildContext context) { + assert( + debugCheckHasMaterialLocalizations(context), + 'No MaterialLocalizations found.', + ); + + final newOrientation = MediaQuery.orientationOf(context); + _lastOrientation ??= newOrientation; + + if (newOrientation != _lastOrientation) { + _removeDropdownRoute(); + _lastOrientation = newOrientation; + } + + final theme = Theme.of(context).extension()!; + + return Semantics( + button: true, + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.surfacePrimary, + boxShadow: [theme.shadows.shadow1], + borderRadius: _kBorderRadius, + border: Border.all(color: theme.borderPrimary), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + splashFactory: InkRipple.splashFactory, + mouseCursor: MaterialStateMouseCursor.clickable, + borderRadius: _kBorderRadius, + onTap: _onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: GyverLampSpacings.md, + horizontal: GyverLampSpacings.lg, + ), + child: Row( + children: [ + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: (child, animation) { + final isNewStatus = + child.key == ValueKey(widget.selected); + + final yOffset = isNewStatus ? 0.5 : -0.5; + + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: Offset(0, yOffset), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + layoutBuilder: ( + Widget? currentChild, + List previousChildren, + ) { + final Widget? previousChild; + + if (previousChildren.isEmpty) { + previousChild = null; + } else { + previousChild = previousChildren.first; + } + + return Stack( + clipBehavior: Clip.antiAlias, + alignment: AlignmentDirectional.centerStart, + children: [ + if (previousChild != null) previousChild, + if (currentChild != null) currentChild, + ], + ); + }, + child: Text( + widget.items[_selectedIndex].label, + key: ValueKey(widget.selected), + style: GyverLampTextStyles.subtitle1.copyWith( + color: theme.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: GyverLampSpacings.lg), + ValueListenableBuilder( + valueListenable: _isOpen, + builder: (context, isOpen, _) { + return Icon( + key: ValueKey(isOpen), + isOpen + ? GyverLampIcons.chevron_up + : GyverLampIcons.chevron_down, + color: theme.textSecondary, + size: _kIconSize, + ); + }, + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _MenuLimits { + const _MenuLimits( + this.top, + this.bottom, + this.height, + this.scrollOffset, + ); + + final double top; + + final double bottom; + + final double height; + + final double scrollOffset; +} + +class _CustomDropdownRoute extends PopupRoute { + _CustomDropdownRoute({ + required this.items, + required this.buttonRect, + required this.selectedIndex, + required this.capturedThemes, + this.barrierLabel, + this.menuMaxHeight, + }); + + final List> items; + + final int selectedIndex; + + final Rect buttonRect; + + final CapturedThemes capturedThemes; + + final double? menuMaxHeight; + + ScrollController? scrollController; + + @override + Duration get transitionDuration => const Duration(milliseconds: 300); + + @override + bool get barrierDismissible => true; + + @override + Color? get barrierColor => null; + + @override + final String? barrierLabel; + + @override + void dispose() { + scrollController?.dispose(); + scrollController = null; + super.dispose(); + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return LayoutBuilder( + builder: ( + BuildContext context, + BoxConstraints constraints, + ) { + if (scrollController == null) { + final menuLimits = getMenuLimits( + constraints.maxHeight, + selectedIndex, + ); + + scrollController = ScrollController( + initialScrollOffset: menuLimits.scrollOffset, + ); + } + + return _CustomDropdownRoutePage( + route: this, + menuConstraints: constraints, + selectedIndex: selectedIndex, + capturedThemes: capturedThemes, + ); + }, + ); + } + + void _dismiss() { + if (isActive) { + navigator?.removeRoute(this); + } + } + + double getItemOffset(int index) { + return index * _kMenuItemHeight; + } + + double getMenuMaxHeight(double viewHeight) { + // The maximum height of a simple menu should be one row less than the view + // height under the button. This ensures a tappable area outside of the + // menu with which to dismiss the menu. + return math.max( + 0, + viewHeight - buttonRect.bottom - _kDropdownOffset - _kMenuItemHeight, + ); + } + + double getMenuWidth(double maxWidth) { + return math.min(maxWidth, buttonRect.width); + } + + // Returns the vertical extent of the menu and the initial scrollOffset + // for the ListView that contains the menu items. + _MenuLimits getMenuLimits( + double availableHeight, + int index, + ) { + final top = buttonRect.bottom + _kDropdownOffset; + final height = availableHeight - _kDropdownOffset; + final bottom = top + height; + + var computedMaxHeight = getMenuMaxHeight(availableHeight); + + if (menuMaxHeight != null) { + computedMaxHeight = math.min(computedMaxHeight, menuMaxHeight!); + } + + final preferredMenuHeight = + _kDropdownPadding * 2 + items.length * _kMaxMenuItemHeight; + + final menuHeight = math.min(computedMaxHeight, preferredMenuHeight); + + final selectedItemOffset = getItemOffset(index); + + var scrollOffset = 0.0; + + // If all of the menu items will not fit within availableHeight then + // compute the scroll offset that will position the selected menu item in + // the center of the menu. + if (preferredMenuHeight > computedMaxHeight) { + // The offset should be zero if the selected item is in view at the + // beginning of the menu. Otherwise, the scroll offset should center the + // item if possible. + scrollOffset = math.max( + 0, + selectedItemOffset - menuHeight / 2 + _kMenuItemHeight, + ); + + // If the selected item's scroll offset is greater than the maximum scroll + // offset, set it instead to the maximum allowed scroll offset. + scrollOffset = math.min( + scrollOffset, + preferredMenuHeight - menuHeight, + ); + } + + return _MenuLimits( + top, + bottom, + menuHeight, + scrollOffset, + ); + } +} + +class _CustomDropdownRoutePage extends StatelessWidget { + const _CustomDropdownRoutePage({ + required this.route, + required this.menuConstraints, + required this.selectedIndex, + required this.capturedThemes, + super.key, + }); + + final _CustomDropdownRoute route; + + final BoxConstraints menuConstraints; + + final int selectedIndex; + + final CapturedThemes capturedThemes; + + @override + Widget build(BuildContext context) { + assert( + debugCheckHasDirectionality(context), + 'No Directionality widget found.', + ); + + final textDirection = Directionality.of(context); + + return MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + removeLeft: true, + removeRight: true, + child: CustomSingleChildLayout( + delegate: _DropdownMenuRouteLayout( + route: route, + textDirection: textDirection, + ), + child: capturedThemes.wrap( + CustomDropdownMenu( + route: route, + menuConstraints: menuConstraints, + ), + ), + ), + ); + } +} + +class _DropdownMenuRouteLayout + extends SingleChildLayoutDelegate { + _DropdownMenuRouteLayout({ + required this.route, + required this.textDirection, + }); + + final _CustomDropdownRoute route; + + final TextDirection textDirection; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + final menuLimits = route.getMenuLimits( + constraints.maxHeight, + route.selectedIndex, + ); + + var maxHeight = menuLimits.height; + + if (route.menuMaxHeight != null && route.menuMaxHeight! <= maxHeight) { + maxHeight = route.menuMaxHeight!; + } + + // The width of a menu should be at most the view width. This ensures that + // the menu does not extend past the left and right edges of the screen. + final width = route.getMenuWidth(constraints.maxWidth); + + return BoxConstraints( + minWidth: width, + maxWidth: width, + maxHeight: maxHeight, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final buttonRect = route.buttonRect; + + final menuLimits = route.getMenuLimits( + size.height, + route.selectedIndex, + ); + + final double left; + + switch (textDirection) { + case TextDirection.rtl: + left = clampDouble(buttonRect.right, 0, size.width) - childSize.width; + + case TextDirection.ltr: + left = clampDouble(buttonRect.left, 0, size.width - childSize.width); + } + + return Offset(left, menuLimits.top); + } + + @override + bool shouldRelayout(_DropdownMenuRouteLayout oldDelegate) { + return route.buttonRect != oldDelegate.route.buttonRect || + textDirection != oldDelegate.textDirection; + } +} + +/// {@template custom_dropdown_menu} +/// Gyver Lamp Custom Dropdown Menu. +/// {@endtemplate} +@visibleForTesting +class CustomDropdownMenu extends StatefulWidget { + /// {@macro custom_dropdown_menu} + const CustomDropdownMenu({ + // ignore: library_private_types_in_public_api + required this.route, + required this.menuConstraints, + super.key, + }); + + /// The route in which this menu is shown. + // ignore: library_private_types_in_public_api + final _CustomDropdownRoute route; + + /// The constraints for the menu. + final BoxConstraints menuConstraints; + + @override + // ignore: library_private_types_in_public_api + _CustomDropdownMenuState createState() => _CustomDropdownMenuState(); +} + +class _CustomDropdownMenuState + extends State> { + late CurvedAnimation _fadeOpacity; + + late CurvedAnimation _resize; + + @override + void initState() { + super.initState(); + + // The menu is shown in three stages: + // [0 - 0.25] - Fade in menu items. + // [0.25 - 0.5] - Grow the menu from the top until it is big enough for as + // many items as we are going to show. + // + // When the menu is dismissed we just fade the entire thing out + // in the first 0.25. + + _fadeOpacity = CurvedAnimation( + parent: widget.route.animation!, + curve: const Interval(0, 0.25, curve: Curves.easeIn), + reverseCurve: const Interval(0.75, 1, curve: Curves.easeOut), + ); + + _resize = CurvedAnimation( + parent: widget.route.animation!, + curve: const Interval(0, 0.5, curve: Curves.easeInOut), + reverseCurve: const Threshold(0), + ); + } + + @override + void dispose() { + _fadeOpacity.dispose(); + _resize.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert( + debugCheckHasMaterialLocalizations(context), + 'No MaterialLocalizations found.', + ); + + final theme = Theme.of(context).extension()!; + + final route = widget.route; + + return RepaintBoundary( + child: FadeTransition( + opacity: _fadeOpacity, + child: Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: MaterialLocalizations.of(context).popupMenuLabel, + child: CustomPaint( + painter: _CustomDropdownMenuBackgroundPainter( + color: theme.surfacePrimary, + shadow: theme.shadows.shadow1, + resize: _resize, + ), + child: ClipRect( + clipper: _CustomDropdownMenuClipper(resize: _resize), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + scrollbars: false, + overscroll: false, + physics: const ClampingScrollPhysics(), + platform: Theme.of(context).platform, + ), + child: PrimaryScrollController( + controller: widget.route.scrollController!, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: _kDropdownPadding, + ), + child: Scrollbar( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _kDropdownPadding, + ), + child: ListView.builder( + primary: true, + padding: EdgeInsets.zero, + itemExtent: _kMaxMenuItemHeight, + itemCount: route.items.length, + itemBuilder: (context, index) { + return _CustomDropdownMenuItemButton( + item: route.items[index], + itemIndex: index, + route: route, + menuConstraints: widget.menuConstraints, + ); + }, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _CustomDropdownMenuBackgroundPainter extends CustomPainter { + _CustomDropdownMenuBackgroundPainter({ + required this.color, + required this.shadow, + required this.resize, + }) : _painter = BoxDecoration( + color: color, + borderRadius: _kBorderRadius, + boxShadow: [shadow], + ).createBoxPainter(), + super(repaint: resize); + + final Color color; + + final BoxShadow shadow; + + final Animation resize; + + final BoxPainter _painter; + + @override + void paint(Canvas canvas, Size size) { + final bottom = Tween( + begin: _kMaxMenuItemHeight, + end: size.height, + ); + + final rect = Rect.fromLTRB( + 0, + 0, + size.width, + bottom.evaluate(resize), + ); + + _painter.paint( + canvas, + rect.topLeft, + ImageConfiguration(size: rect.size), + ); + } + + @override + bool shouldRepaint(_CustomDropdownMenuBackgroundPainter oldPainter) { + return oldPainter.color != color || + oldPainter.shadow != shadow || + oldPainter.resize != resize; + } +} + +class _CustomDropdownMenuClipper extends CustomClipper { + _CustomDropdownMenuClipper({ + required this.resize, + }) : super(reclip: resize); + + final Animation resize; + + @override + Rect getClip(Size size) { + final bottom = Tween( + begin: _kMaxMenuItemHeight, + end: size.height, + ); + + return Rect.fromLTRB( + 0, + 0, + size.width, + bottom.evaluate(resize), + ); + } + + @override + bool shouldReclip(_CustomDropdownMenuClipper oldClipper) { + return oldClipper.resize != resize; + } +} + +class _CustomDropdownMenuItemButton extends StatefulWidget { + const _CustomDropdownMenuItemButton({ + required this.item, + required this.route, + required this.menuConstraints, + required this.itemIndex, + super.key, + }); + + final CustomDropdownMenuItem item; + + final _CustomDropdownRoute route; + + final BoxConstraints menuConstraints; + + final int itemIndex; + + @override + _CustomDropdownMenuItemButtonState createState() => + _CustomDropdownMenuItemButtonState(); +} + +class _CustomDropdownMenuItemButtonState + extends State<_CustomDropdownMenuItemButton> { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + final itemIndex = widget.itemIndex; + final selectedIndex = widget.route.selectedIndex; + + final selected = selectedIndex == itemIndex; + final showDivider = + itemIndex != 0 && !selected && itemIndex - 1 != selectedIndex; + + final style = selected + ? GyverLampTextStyles.subtitle2.copyWith( + color: theme.textPrimary, + ) + : GyverLampTextStyles.subtitle2.copyWith( + color: theme.textSecondary, + ); + + return RepaintBoundary( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (showDivider) + const Padding( + padding: EdgeInsets.symmetric(horizontal: GyverLampSpacings.sm), + child: Divider( + height: _kMenuDividerHeight, + thickness: _kMenuDividerHeight, + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(minHeight: _kMenuItemHeight), + child: DecoratedBox( + decoration: BoxDecoration( + color: selected ? theme.surfaceSecondary : theme.surfacePrimary, + borderRadius: _kBorderRadius, + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + splashFactory: InkRipple.splashFactory, + borderRadius: _kBorderRadius, + onTap: () { + Navigator.of(context).pop(widget.item.value); + }, + child: Padding( + padding: const EdgeInsets.only( + top: GyverLampSpacings.md, + bottom: GyverLampSpacings.md, + left: GyverLampSpacings.sm, + right: GyverLampSpacings.lg, + ), + child: Row( + children: [ + Expanded( + child: Text( + widget.item.label, + style: style, + overflow: TextOverflow.ellipsis, + ), + ), + if (selected) ...[ + GyverLampGaps.sm, + Icon( + GyverLampIcons.done, + color: theme.textPrimary, + size: _kIconSize, + ), + ], + ], + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/flat_icon_button.dart b/packages/gyver_lamp_ui/lib/src/widgets/flat_icon_button.dart new file mode 100644 index 0000000..fbe4c70 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/flat_icon_button.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +/// Represents the size of the [FlatIconButton]. +enum FlatIconButtonSize { + /// Represents a medium size. + medium, + + /// Represents a large size. + large, +} + +/// {@template flat_icon_button} +/// Gyver Lamp Flat Icon Button. +/// {@endtemplate} +class FlatIconButton extends StatelessWidget { + /// {@macro flat_icon_button} + const FlatIconButton({ + required this.size, + required this.icon, + required this.onPressed, + this.color, + super.key, + }); + + /// Medium size [FlatIconButton]. + const FlatIconButton.medium({ + required this.icon, + required this.onPressed, + this.color, + super.key, + }) : size = FlatIconButtonSize.medium; + + /// Large size [FlatIconButton]. + const FlatIconButton.large({ + required this.icon, + required this.onPressed, + this.color, + super.key, + }) : size = FlatIconButtonSize.large; + + /// The size of the button. + final FlatIconButtonSize size; + + /// The icon of the button. + final IconData icon; + + /// Called when the button is tapped. + final VoidCallback? onPressed; + + /// The color of the button. + final Color? color; + + /// Length of the button's side. + double get dimension => switch (size) { + FlatIconButtonSize.medium => 32, + FlatIconButtonSize.large => 44, + }; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return RepaintBoundary( + child: SizedBox.square( + dimension: dimension, + child: DecoratedBox( + decoration: ShapeDecoration( + color: color ?? theme.surfacePrimary, + shape: const CircleBorder(), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + splashFactory: InkRipple.splashFactory, + customBorder: const CircleBorder(), + mouseCursor: onPressed == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + onTap: onPressed, + child: Center( + child: Icon( + icon, + size: switch (size) { + (FlatIconButtonSize.medium) => 24, + (FlatIconButtonSize.large) => 16, + }, + color: onPressed == null + ? theme.textSecondary.withOpacity(0.5) + : theme.textSecondary, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/flat_text_button.dart b/packages/gyver_lamp_ui/lib/src/widgets/flat_text_button.dart new file mode 100644 index 0000000..4ce0878 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/flat_text_button.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +// The corner radius of the button. +const _kBorderRadius = Radius.circular(8); + +/// Represents the size of the [FlatTextButton]. +enum FlatTextButtonSize { + /// Represents a small size. + small, + + /// Represents a medium size. + medium, + + /// Represents a large size. + large, +} + +/// {@template flat_text_button} +/// Gyver Lamp Flat Text Button. +/// {@endtemplate} +class FlatTextButton extends StatelessWidget { + /// {@macro flat_text_button} + const FlatTextButton({ + required this.size, + required this.child, + required this.onPressed, + super.key, + }); + + /// Small size [FlatTextButton]. + const FlatTextButton.small({ + required this.child, + required this.onPressed, + super.key, + }) : size = FlatTextButtonSize.small; + + /// Medium size [FlatTextButton]. + const FlatTextButton.medium({ + required this.child, + required this.onPressed, + super.key, + }) : size = FlatTextButtonSize.medium; + + /// Large size [FlatTextButton]. + const FlatTextButton.large({ + required this.child, + required this.onPressed, + super.key, + }) : size = FlatTextButtonSize.large; + + /// The size of the button. + final FlatTextButtonSize size; + + /// The child widget of the button. + /// + /// Typically the button's label (e.g. [Text] text). + final Widget child; + + /// Called when the button is tapped. + final VoidCallback? onPressed; + + /// The height of the button. + double get height => switch (size) { + (FlatTextButtonSize.small) => 32, + (FlatTextButtonSize.medium) => 40, + (FlatTextButtonSize.large) => 48, + }; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return RepaintBoundary( + child: Container( + height: height, + decoration: const ShapeDecoration( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(_kBorderRadius), + ), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + splashFactory: InkRipple.splashFactory, + borderRadius: const BorderRadius.all(_kBorderRadius), + mouseCursor: onPressed == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: GyverLampSpacings.lg, + ), + child: DefaultTextStyle( + style: switch (size) { + (FlatTextButtonSize.small) => + GyverLampTextStyles.buttonSmallBold, + (FlatTextButtonSize.medium) => + GyverLampTextStyles.buttonMediumBold, + (FlatTextButtonSize.large) => + GyverLampTextStyles.buttonLargeBold, + } + .copyWith( + color: onPressed != null + ? theme.textSecondary + : theme.textSecondary.withOpacity(0.5), + ), + overflow: TextOverflow.ellipsis, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: child, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/gyver_lamp_dialog.dart b/packages/gyver_lamp_ui/lib/src/widgets/gyver_lamp_dialog.dart new file mode 100644 index 0000000..c1fe478 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/gyver_lamp_dialog.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +// The corner radius of the dialog. +const _kBorderRadius = Radius.circular(8); + +/// {@template custom_dialog} +/// Gyver Lamp Custom Dialog. +/// {@endtemplate} +class GyverLampDialog extends StatelessWidget { + /// {@macro custom_dialog} + const GyverLampDialog({ + required this.title, + required this.body, + required this.actions, + super.key, + }); + + /// The title on top of the dialog. + final String title; + + /// The widget which represents a body of the dialog. + final Widget body; + + /// The list of action on the bottom of the dialog. + final List actions; + + /// Shows the dialog. + static Future show( + BuildContext context, { + required Widget dialog, + }) async { + return showGeneralDialog( + context: context, + pageBuilder: (context, _, __) => dialog, + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + final curvedAnimation = Curves.easeOutBack.transform(animation.value); + final dy = (1 - curvedAnimation) * 40; + + return Opacity( + opacity: Curves.easeOutQuart.transform(animation.value), + child: Transform.translate( + offset: Offset(0, dy), + child: child, + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return Dialog( + backgroundColor: theme.background, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(_kBorderRadius), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Padding( + padding: const EdgeInsets.all(GyverLampSpacings.lg), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GyverLampTextStyles.headline6Bold.copyWith( + color: theme.textPrimary, + ), + ), + const SizedBox(height: GyverLampSpacings.lg), + DefaultTextStyle( + style: GyverLampTextStyles.body2.copyWith( + color: theme.textPrimary, + ), + child: body, + ), + const SizedBox(height: GyverLampSpacings.xlg), + Align( + alignment: AlignmentDirectional.centerEnd, + child: Wrap( + alignment: WrapAlignment.end, + spacing: GyverLampSpacings.sm, + runSpacing: GyverLampSpacings.sm, + children: actions, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/gyver_lamp_gaps.dart b/packages/gyver_lamp_ui/lib/src/widgets/gyver_lamp_gaps.dart new file mode 100644 index 0000000..da7acba --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/gyver_lamp_gaps.dart @@ -0,0 +1,35 @@ +import 'package:gap/gap.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +/// Gaps used in the Gyver Lamp UI. +abstract class GyverLampGaps { + /// xxxs gap (1pt) + static const Gap xxxs = Gap(GyverLampSpacings.xxxs); + + /// xxs gap (2pt) + static const Gap xxs = Gap(GyverLampSpacings.xxs); + + /// xs gap (4pt) + static const Gap xs = Gap(GyverLampSpacings.xs); + + /// sm gap (8pt) + static const Gap sm = Gap(GyverLampSpacings.sm); + + /// md gap (12pt) + static const Gap md = Gap(GyverLampSpacings.md); + + /// lg gap (16pt) + static const Gap lg = Gap(GyverLampSpacings.lg); + + /// smxlg gap (20pt) + static const Gap xlgsm = Gap(GyverLampSpacings.xlgsm); + + /// xlg gap (24pt) + static const Gap xlg = Gap(GyverLampSpacings.xlg); + + /// xxlg gap (40pt) + static const Gap xxlg = Gap(GyverLampSpacings.xxlg); + + /// xxxlg pacing value (64pt) + static const Gap xxxlg = Gap(GyverLampSpacings.xxxlg); +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/labeled_input_field.dart b/packages/gyver_lamp_ui/lib/src/widgets/labeled_input_field.dart new file mode 100644 index 0000000..f87e834 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/labeled_input_field.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +/// {@template labeled_input_field} +/// Gyver Lamp Labeled Input Field. +/// {@endtemplate} +class LabeledInputField extends StatefulWidget { + /// {@macro labeled_input_field} + const LabeledInputField({ + required this.label, + this.controller, + this.focusNode, + this.keyboardType, + this.hintText, + this.errorText, + this.onChanged, + this.enabled = true, + super.key, + }); + + /// Label placed on top of input field. + final String label; + + /// Controls the text being edited. + final TextEditingController? controller; + + /// Defines the keyboard focus for this widget. + final FocusNode? focusNode; + + /// The type of information for which to optimize the text input control. + final TextInputType? keyboardType; + + /// The text that suggests what sort of input the field accepts. + final String? hintText; + + /// The text that appears below the field and indicates an error. + final String? errorText; + + /// The callback that is called when a new text is entered. + final ValueChanged? onChanged; + + /// Whether the field is enabled and can be edited. + final bool enabled; + + /// Whether error exists. + bool get hasError => errorText != null && errorText!.isNotEmpty; + + @override + State createState() => _LabeledInputFieldState(); +} + +class _LabeledInputFieldState extends State { + late final _internalFocusNode = FocusNode(); + late final _internalController = TextEditingController(); + + FocusNode get _effectiveFocusNode => widget.focusNode ?? _internalFocusNode; + TextEditingController get _effectiveController => + widget.controller ?? _internalController; + + @override + void dispose() { + _internalFocusNode.dispose(); + _internalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: GyverLampSpacings.xs, + ), + child: Text( + widget.label, + style: GyverLampTextStyles.subtitle2.copyWith( + color: theme.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox( + height: GyverLampSpacings.xs, + ), + Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + boxShadow: widget.enabled ? [theme.shadows.shadow1] : null, + ), + ), + ), + TextField( + focusNode: _effectiveFocusNode, + controller: _effectiveController, + keyboardType: widget.keyboardType, + decoration: InputDecoration( + hintText: widget.hintText, + contentPadding: const EdgeInsets.all(GyverLampSpacings.md), + isCollapsed: true, + suffixIcon: RepaintBoundary( + child: ListenableBuilder( + listenable: _effectiveController, + builder: (context, _) { + return AnimatedOpacity( + opacity: _effectiveController.text.isEmpty ? 0 : 1, + duration: const Duration(milliseconds: 100), + curve: Curves.easeInOut, + child: GestureDetector( + child: const Icon( + GyverLampIcons.close, + size: 24, + ), + onTap: () { + _effectiveFocusNode.requestFocus(); + _effectiveController.clear(); + }, + ), + ); + }, + ), + ), + ), + enabled: widget.enabled, + enableSuggestions: false, + autocorrect: false, + textAlignVertical: TextAlignVertical.center, + style: GyverLampTextStyles.body2.copyWith( + color: theme.textSecondary, + ), + cursorRadius: const Radius.circular(2), + cursorWidth: 1.5, + onChanged: (value) { + final onChanged = widget.onChanged; + + if (onChanged == null) { + return; + } + + onChanged(value); + }, + ), + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 150), + opacity: widget.hasError ? 1 : 0, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all(color: theme.notConnectedText), + ), + ), + ), + ), + ), + ], + ), + if (widget.hasError) _Error(errorText: widget.errorText!), + ], + ); + } +} + +class _Error extends StatefulWidget { + const _Error({ + required this.errorText, + }); + + final String errorText; + + @override + State<_Error> createState() => _ErrorState(); +} + +class _ErrorState extends State<_Error> with SingleTickerProviderStateMixin { + late final _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 150), + ); + + late final _opacity = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ); + + @override + void initState() { + super.initState(); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + _opacity.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return FadeTransition( + opacity: _opacity, + child: Padding( + padding: const EdgeInsets.only( + left: GyverLampSpacings.xs, + right: GyverLampSpacings.xs, + top: GyverLampSpacings.xs, + ), + child: Text( + widget.errorText, + style: GyverLampTextStyles.caption.copyWith( + color: theme.notConnectedText, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/rounded_elevated_button.dart b/packages/gyver_lamp_ui/lib/src/widgets/rounded_elevated_button.dart new file mode 100644 index 0000000..c86366f --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/rounded_elevated_button.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +// The corner radius of the button. +const _kBorderRadius = Radius.circular(8); + +/// Represents the size of the [RoundedElevatedButton]. +enum RoundedElevatedButtonSize { + /// Represents a small size. + small, + + /// Represents a medium size. + medium, + + /// Represents a large size. + large, +} + +/// {@template rounded_elevated_button} +/// Gyver Lamp Rounded Elevated Button. +/// {@endtemplate} +class RoundedElevatedButton extends StatefulWidget { + /// {@macro rounded_elevated_button} + const RoundedElevatedButton({ + required this.size, + required this.child, + required this.onPressed, + super.key, + }); + + /// Small size [RoundedElevatedButton]. + const RoundedElevatedButton.small({ + required this.child, + required this.onPressed, + super.key, + }) : size = RoundedElevatedButtonSize.small; + + /// Medium size [RoundedElevatedButton]. + const RoundedElevatedButton.medium({ + required this.child, + required this.onPressed, + super.key, + }) : size = RoundedElevatedButtonSize.medium; + + /// Large size [RoundedElevatedButton]. + const RoundedElevatedButton.large({ + required this.child, + required this.onPressed, + super.key, + }) : size = RoundedElevatedButtonSize.large; + + /// The size of the button. + final RoundedElevatedButtonSize size; + + /// The child widget of the button. + /// + /// Typically the button's label (e.g. [Text] text). + final Widget child; + + /// Called when the button is tapped. + final VoidCallback? onPressed; + + /// The height of the button. + double get height => switch (size) { + (RoundedElevatedButtonSize.small) => 32, + (RoundedElevatedButtonSize.medium) => 40, + (RoundedElevatedButtonSize.large) => 48, + }; + + @override + State createState() => RoundedElevatedButtonState(); +} + +/// Gyver Lamp Rounded Elevated Button state. +class RoundedElevatedButtonState extends State { + /// Whether the button is pressed. + @visibleForTesting + bool isPressed = false; + + @override + void setState(VoidCallback fn) { + if (mounted) super.setState(fn); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return RepaintBoundary( + child: AnimatedContainer( + height: widget.height, + duration: const Duration(milliseconds: 250), + decoration: ShapeDecoration( + color: widget.onPressed != null + ? theme.onBackground + : theme.buttonDisabled, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(_kBorderRadius), + ), + shadows: [ + if (widget.onPressed != null && !isPressed) theme.shadows.shadow2, + ], + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + splashFactory: InkRipple.splashFactory, + borderRadius: const BorderRadius.all(_kBorderRadius), + mouseCursor: widget.onPressed == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + onTap: widget.onPressed, + onTapDown: widget.onPressed == null + ? null + : (_) => setState(() => isPressed = true), + onTapUp: widget.onPressed == null + ? null + : (_) => setState(() => isPressed = false), + onTapCancel: widget.onPressed == null + ? null + : () => setState(() => isPressed = false), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: GyverLampSpacings.lg, + ), + child: DefaultTextStyle( + style: switch (widget.size) { + (RoundedElevatedButtonSize.small) => + GyverLampTextStyles.buttonSmallBold, + (RoundedElevatedButtonSize.medium) => + GyverLampTextStyles.buttonMediumBold, + (RoundedElevatedButtonSize.large) => + GyverLampTextStyles.buttonLargeBold, + } + .copyWith( + color: widget.onPressed != null + ? theme.background + : theme.textButtonDisabled, + ), + overflow: TextOverflow.ellipsis, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: widget.child, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/rounded_outlined_button.dart b/packages/gyver_lamp_ui/lib/src/widgets/rounded_outlined_button.dart new file mode 100644 index 0000000..5c73d6b --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/rounded_outlined_button.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +// The corner radius of the button. +const _kBorderRadius = Radius.circular(8); + +/// Represents the size of the [RoundedOutlinedButton]. +enum RoundedOutlinedButtonSize { + /// Represents a small size. + small, + + /// Represents a medium size. + medium, + + /// Represents a large size. + large, +} + +/// {@template rounded_outlined_button} +/// Gyver Lamp Rounded Outlined Button. +/// {@endtemplate} +class RoundedOutlinedButton extends StatefulWidget { + /// {@macro rounded_outlined_button} + const RoundedOutlinedButton({ + required this.size, + required this.child, + required this.onPressed, + super.key, + }); + + /// Small size [RoundedOutlinedButton]. + const RoundedOutlinedButton.small({ + required this.child, + required this.onPressed, + super.key, + }) : size = RoundedOutlinedButtonSize.small; + + /// Medium size [RoundedOutlinedButton]. + const RoundedOutlinedButton.medium({ + required this.child, + required this.onPressed, + super.key, + }) : size = RoundedOutlinedButtonSize.medium; + + /// Large size [RoundedOutlinedButton]. + const RoundedOutlinedButton.large({ + required this.child, + required this.onPressed, + super.key, + }) : size = RoundedOutlinedButtonSize.large; + + /// The size of the button. + final RoundedOutlinedButtonSize size; + + /// The child widget of the button. + /// + /// Typically the button's label (e.g. [Text] widget). + final Widget child; + + /// Called when the button is tapped. + final VoidCallback? onPressed; + + /// The height of the button. + double get height => switch (size) { + (RoundedOutlinedButtonSize.small) => 32, + (RoundedOutlinedButtonSize.medium) => 40, + (RoundedOutlinedButtonSize.large) => 48, + }; + + @override + State createState() => RoundedOutlinedButtonState(); +} + +/// Gyver Lamp Rounded Outlined Button state. +class RoundedOutlinedButtonState extends State { + /// Whether the button is pressed. + @visibleForTesting + bool isPressed = false; + + @override + void setState(VoidCallback fn) { + if (mounted) super.setState(fn); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return RepaintBoundary( + child: SizedBox( + height: widget.height, + child: DecoratedBox( + decoration: ShapeDecoration( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(_kBorderRadius), + side: BorderSide(color: theme.textSecondary), + ), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + splashFactory: InkRipple.splashFactory, + borderRadius: const BorderRadius.all(_kBorderRadius), + mouseCursor: widget.onPressed == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + onTap: widget.onPressed, + onTapDown: widget.onPressed == null + ? null + : (_) => setState(() => isPressed = true), + onTapUp: widget.onPressed == null + ? null + : (_) => setState(() => isPressed = false), + onTapCancel: widget.onPressed == null + ? null + : () => setState(() => isPressed = false), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: GyverLampSpacings.lg, + ), + child: DefaultTextStyle( + style: switch (widget.size) { + (RoundedOutlinedButtonSize.small) => + GyverLampTextStyles.buttonSmallBold, + (RoundedOutlinedButtonSize.medium) => + GyverLampTextStyles.buttonMediumBold, + (RoundedOutlinedButtonSize.large) => + GyverLampTextStyles.buttonLargeBold, + } + .copyWith( + color: widget.onPressed != null + ? theme.textSecondary + : theme.textSecondary.withOpacity(0.5), + ), + overflow: TextOverflow.ellipsis, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: widget.child, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/ruler.dart b/packages/gyver_lamp_ui/lib/src/widgets/ruler.dart new file mode 100644 index 0000000..18de6a0 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/ruler.dart @@ -0,0 +1,709 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:tactile_feedback/tactile_feedback.dart'; + +// The update animation curve. +const _kUpdateAnimationCurve = Curves.easeInOut; + +// The duration of update animation. +const _kUpdateAnimationDuration = Duration(milliseconds: 250); + +// The short duration of update animation. +const _kUpdateAnimationShortDuration = Duration(milliseconds: 50); + +// The height of the ruler. +const _kRulerHeight = 48.0; + +// The width of the pointer mark. +const _kPointerMarkWidth = 3.0; + +// The size of the pointer mark. +const _kPointerMarkSize = Size(_kPointerMarkWidth, 24); + +/// The width of the mark. +@visibleForTesting +const kMarkWidth = 1.5; + +// The size of the small mark. +const _kSmallMarkSize = Size(kMarkWidth, 8); + +// The size of the large mark. +const _kLargeMarkSize = Size(kMarkWidth, 18); + +/// The width of gap between marks. +@visibleForTesting +const kGapWidth = GyverLampSpacings.sm; + +// The corner radius of the ruler. +const _kBorderRadius = Radius.circular(8); + +/// Calculates the offset for ruler depending on the item index. +double _getOffsetFromIndex({ + required int index, + required double itemExtent, + required double gapExtent, +}) { + return index * gapExtent + index * itemExtent + itemExtent / 2; +} + +/// Calculates the index of the ruler item depending on the offset. +int _getIndexFromOffset({ + required double offset, + required double itemExtent, + required double gapExtent, + required double minScrollExtent, + required double maxScrollExtent, +}) { + final o = math.min(math.max(offset, minScrollExtent), maxScrollExtent); + + final i = (o - itemExtent / 2) / (itemExtent + gapExtent); + + return i < 0 ? 0 : i.round().abs(); +} + +/// A snapping physics that always lands directly on items instead of anywhere +/// within the scroll extent. +/// +/// Must be used with a scrollable that uses a [_RulerScrollController]. +class _RulerScrollPhysics extends ScrollPhysics { + const _RulerScrollPhysics() + // coverage:ignore-start + : super( + // coverage:ignore-end + parent: const BouncingScrollPhysics( + parent: RangeMaintainingScrollPhysics(), + ), + ); + + @override + _RulerScrollPhysics applyTo(ScrollPhysics? ancestor) { + return const _RulerScrollPhysics(); + } + + @override + SpringDescription get spring => const SpringDescription( + mass: 1, + stiffness: 180, + damping: 28, + ); + + @override + Simulation? createBallisticSimulation( + ScrollMetrics position, + double velocity, + ) { + assert( + position is _RulerScrollPosition, + 'RulerScrollPhysics can only be used with scrollables that uses ' + 'the RulerScrollController', + ); + + final metrics = position as _RulerScrollPosition; + + // Scenario 1: + // If we're out of range and not headed back in range, defer to the parent + // ballistics, which should put us back in range at the scrollable's + // boundary. + if ((velocity <= 0.0 && metrics.pixels <= metrics.minScrollExtent) || + (velocity >= 0.0 && metrics.pixels >= metrics.maxScrollExtent)) { + return super.createBallisticSimulation(metrics, velocity); + } + + // Create a test simulation to see where it would have ballistically fallen + // naturally without settling onto items. + final testFrictionSimulation = super.createBallisticSimulation( + metrics, + velocity, + ); + + // Scenario 2: + // If it was going to end up past the scroll extent, defer back to the + // parent physics' ballistics again which should put us on the scrollable's + // boundary. + if (testFrictionSimulation != null && + (testFrictionSimulation.x(double.infinity) <= metrics.minScrollExtent || + testFrictionSimulation.x(double.infinity) >= + metrics.maxScrollExtent)) { + return super.createBallisticSimulation(metrics, velocity); + } + + // From the natural final position, find the nearest item it should have + // settled to. + final settlingItemIndex = _getIndexFromOffset( + offset: velocity.abs() > 100 + ? (testFrictionSimulation?.x(double.infinity) ?? metrics.pixels) + : metrics.pixels, + itemExtent: metrics.itemExtent, + gapExtent: metrics.gapExtent, + minScrollExtent: metrics.minScrollExtent, + maxScrollExtent: metrics.maxScrollExtent, + ); + + final settlingPixels = _getOffsetFromIndex( + index: settlingItemIndex, + itemExtent: metrics.itemExtent, + gapExtent: metrics.gapExtent, + ); + + // Scenario 3: + // If there's no velocity and we're already at where we intend to land, + // do nothing. + if (velocity.abs() < toleranceFor(position).velocity && + (settlingPixels - metrics.pixels).abs() < + toleranceFor(position).distance * 2) { + return null; + } + + // Scenario 4: + // If we're going to end back at the same item because initial velocity + // is too low to break past it, use a spring simulation to get back. + if (settlingItemIndex == metrics.itemIndex) { + return SpringSimulation( + spring, + metrics.pixels, + settlingPixels, + 0, + tolerance: toleranceFor(position), + ); + } + + // Scenario 5: + // Create a new friction simulation except the drag will be tweaked to land + // exactly on the item closest to the natural stopping point. + return FrictionSimulation.through( + metrics.pixels, + settlingPixels, + velocity, + toleranceFor(position).velocity * velocity.sign, + ); + } +} + +/// A scroll controller for [Ruler] widget. +/// +/// Similar to a standard [ScrollController] but with the added convenience +/// mechanisms to read and go to item indices rather than a raw pixel scroll +/// offset. +class _RulerScrollController extends ScrollController { + _RulerScrollController({ + required this.itemExtent, + required this.gapExtent, + this.initialItem = 0, + }); + + /// Size of each item in the main axis. + final double itemExtent; + + /// Size of each gap in the main axis. + final double gapExtent; + + /// The page to show when first creating the scroll view. + final int initialItem; + + /// Animates the ruler to the given item index. + /// + /// The animation lasts for the given duration and follows the given curve. + /// The returned [Future] resolves when the animation completes. + /// + /// The `duration` and `curve` arguments must not be null. + Future animateToItem( + int itemIndex, { + required Duration duration, + required Curve curve, + }) async { + if (!hasClients) { + return; + } + + await Future.wait(>[ + for (final position in positions.cast<_RulerScrollPosition>()) + position.animateTo( + _getOffsetFromIndex( + index: itemIndex, + itemExtent: position.itemExtent, + gapExtent: position.gapExtent, + ), + duration: duration, + curve: curve, + ), + ]); + } + + @override + ScrollPosition createScrollPosition( + ScrollPhysics physics, + ScrollContext context, + ScrollPosition? oldPosition, + ) { + return _RulerScrollPosition( + physics: physics, + context: context, + itemExtent: itemExtent, + gapExtent: gapExtent, + initialItem: initialItem, + oldPosition: oldPosition, + ); + } +} + +/// Metrics for a ruler [ScrollPosition]. +class _RulerScrollMetrics extends FixedScrollMetrics { + _RulerScrollMetrics({ + required super.minScrollExtent, + required super.maxScrollExtent, + required super.pixels, + required super.viewportDimension, + required super.axisDirection, + required super.devicePixelRatio, + required this.itemIndex, + }); + + /// The currently selected item index. + final int itemIndex; + + // coverage:ignore-start + @override + _RulerScrollMetrics copyWith({ + double? minScrollExtent, + double? maxScrollExtent, + double? pixels, + double? viewportDimension, + AxisDirection? axisDirection, + double? devicePixelRatio, + int? itemIndex, + }) { + return _RulerScrollMetrics( + minScrollExtent: minScrollExtent ?? + (hasContentDimensions ? this.minScrollExtent : null), + maxScrollExtent: maxScrollExtent ?? + (hasContentDimensions ? this.maxScrollExtent : null), + pixels: pixels ?? (hasPixels ? this.pixels : null), + viewportDimension: viewportDimension ?? + (hasViewportDimension ? this.viewportDimension : null), + axisDirection: axisDirection ?? this.axisDirection, + devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, + itemIndex: itemIndex ?? this.itemIndex, + ); + } + // coverage:ignore-end +} + +/// A [ScrollPosition] that is used by [_RulerScrollController]. +class _RulerScrollPosition extends ScrollPositionWithSingleContext + implements _RulerScrollMetrics { + _RulerScrollPosition({ + required super.physics, + required super.context, + required this.itemExtent, + required this.gapExtent, + required int initialItem, + super.oldPosition, + }) : super( + initialPixels: _getOffsetFromIndex( + index: initialItem, + itemExtent: itemExtent, + gapExtent: gapExtent, + ), + ); + + final double itemExtent; + + final double gapExtent; + + @override + int get itemIndex { + return _getIndexFromOffset( + offset: pixels, + itemExtent: itemExtent, + gapExtent: gapExtent, + minScrollExtent: minScrollExtent, + maxScrollExtent: maxScrollExtent, + ); + } + + @override + _RulerScrollMetrics copyWith({ + double? minScrollExtent, + double? maxScrollExtent, + double? pixels, + double? viewportDimension, + AxisDirection? axisDirection, + double? devicePixelRatio, + int? itemIndex, + }) { + return _RulerScrollMetrics( + minScrollExtent: minScrollExtent ?? + (hasContentDimensions ? this.minScrollExtent : null), + maxScrollExtent: maxScrollExtent ?? + (hasContentDimensions ? this.maxScrollExtent : null), + pixels: pixels ?? (hasPixels ? this.pixels : null), + viewportDimension: viewportDimension ?? + (hasViewportDimension ? this.viewportDimension : null), + axisDirection: axisDirection ?? this.axisDirection, + devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, + itemIndex: itemIndex ?? this.itemIndex, + ); + } +} + +/// A widget which draws mark for [Ruler]. +class _Mark extends StatelessWidget { + const _Mark({ + required this.color, + required this.size, + }); + + final Color color; + + final Size size; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _MarkPainter(color: color), + size: size, + ); + } +} + +/// A [CustomPainter] for [_Mark] widget. +class _MarkPainter extends CustomPainter { + const _MarkPainter({ + required this.color, + }); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final rect = RRect.fromLTRBR( + 0, + 0, + size.width, + size.height, + Radius.circular(size.width), + ); + + canvas.drawRRect( + rect, + Paint()..color = color, + ); + } + + @override + bool shouldRepaint(_MarkPainter oldDelegate) { + return color != oldDelegate.color; + } +} + +/// An [ImplicitlyAnimatedWidget] for integer value represented as [Text]. +class _AnimatedValueText extends ImplicitlyAnimatedWidget { + const _AnimatedValueText({ + required this.value, + required super.duration, + required super.curve, + }); + + final int value; + + @override + ImplicitlyAnimatedWidgetState createState() => + _AnimatedValueTextState(); +} + +class _AnimatedValueTextState + extends AnimatedWidgetBaseState<_AnimatedValueText> { + IntTween? _valueTween; + + @override + void forEachTween(TweenVisitor visitor) { + _valueTween = visitor( + _valueTween, + widget.value, + (value) => IntTween(begin: value as int), + )! as IntTween; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return Text( + _valueTween!.evaluate(animation).toString(), + style: GyverLampTextStyles.captionBold.copyWith( + color: theme.textPrimary, + ), + textAlign: TextAlign.left, + ); + } +} + +/// Custom scroll behavior for [Ruler] widget. +class _RulerScrollBehavior extends ScrollBehavior { + const _RulerScrollBehavior(); + + @override + GestureVelocityTrackerBuilder velocityTrackerBuilder(BuildContext context) { + switch (getPlatform(context)) { + case TargetPlatform.iOS: + return (PointerEvent event) => + IOSScrollViewFlingVelocityTracker(event.kind); + + case TargetPlatform.macOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return (PointerEvent event) => VelocityTracker.withKind(event.kind); + } + } + + @override + Widget buildOverscrollIndicator( + BuildContext context, + Widget child, + ScrollableDetails details, + ) { + return child; + } + + @override + Widget buildScrollbar( + BuildContext context, + Widget child, + ScrollableDetails details, + ) { + return child; + } +} + +/// {@template ruler} +/// Gyver Lamp Ruler. +/// {@endtemplate} +class Ruler extends StatefulWidget { + /// {@macro ruler} + const Ruler({ + required this.value, + required this.maxValue, + required this.onChanged, + super.key, + }) : assert( + value > 0 && value <= maxValue, + 'value have to be in range [1, maxValue]', + ); + + /// The current value of the ruler. + final int value; + + /// The maximum available value in the ruler. + final int maxValue; + + /// The callback that is called when a new value is selected. + final ValueChanged onChanged; + + @override + State createState() => _RulerState(); +} + +class _RulerState extends State { + late final _RulerScrollController _scrollController; + + int _currentIndex = 0; + + int _lastReportedItemIndex = 0; + + bool _isDragging = false; + + bool _isScrolling = false; + + bool _isAnimating = false; + + @override + void initState() { + super.initState(); + + _currentIndex = widget.value - 1; + _lastReportedItemIndex = _currentIndex; + + _scrollController = _RulerScrollController( + itemExtent: kMarkWidth, + gapExtent: kGapWidth, + initialItem: _currentIndex, + ); + } + + @override + void didUpdateWidget(Ruler oldWidget) { + super.didUpdateWidget(oldWidget); + + _currentIndex = widget.value - 1; + + if (oldWidget.value != widget.value && !_isDragging && !_isScrolling) { + _isAnimating = true; + + _scrollController + .animateToItem( + _currentIndex, + duration: _kUpdateAnimationDuration, + curve: _kUpdateAnimationCurve, + ) + .then((_) => _isAnimating = false) + .ignore(); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + bool _handleScrollNotification(ScrollNotification notification) { + if (notification is ScrollStartNotification) { + _isScrolling = true; + return true; + } + + if (notification is ScrollEndNotification) { + _isScrolling = false; + return true; + } + + if (notification is ScrollUpdateNotification && + notification.metrics is _RulerScrollMetrics) { + final metrics = notification.metrics as _RulerScrollMetrics; + + final currentItemIndex = metrics.itemIndex; + + if (currentItemIndex != _lastReportedItemIndex) { + TactileFeedback.impact(); + + _lastReportedItemIndex = currentItemIndex; + + if (!_isAnimating) { + widget.onChanged(currentItemIndex + 1); + } + } + + return true; + } + + return false; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return RepaintBoundary( + child: ScrollConfiguration( + behavior: const _RulerScrollBehavior(), + child: Listener( + onPointerDown: (_) { + _isDragging = true; + }, + onPointerUp: (_) { + _isDragging = false; + }, + child: NotificationListener( + onNotification: _handleScrollNotification, + child: SizedBox( + height: _kRulerHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.surfacePrimary, + boxShadow: [theme.shadows.shadow1], + borderRadius: const BorderRadius.all(_kBorderRadius), + border: Border.all(color: theme.borderPrimary), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: GyverLampSpacings.lg, + ), + child: Row( + children: [ + Expanded( + child: Stack( + alignment: Alignment.center, + children: [ + LayoutBuilder( + builder: (context, constraints) { + return ListView.separated( + addRepaintBoundaries: false, + addAutomaticKeepAlives: false, + cacheExtent: 100, + itemCount: widget.maxValue, + physics: const _RulerScrollPhysics(), + controller: _scrollController, + padding: EdgeInsets.symmetric( + horizontal: constraints.biggest.width / 2, + ), + scrollDirection: Axis.horizontal, + separatorBuilder: (context, index) { + return const SizedBox(width: kGapWidth); + }, + itemBuilder: (context, index) { + if (index.isOdd) { + return Center( + child: _Mark( + color: theme.textSecondary, + size: _kSmallMarkSize, + ), + ); + } + + return Center( + child: _Mark( + color: theme.textSecondary, + size: _kLargeMarkSize, + ), + ); + }, + ); + }, + ), + IgnorePointer( + child: _Mark( + color: theme.pointer, + size: _kPointerMarkSize, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only( + top: GyverLampSpacings.md, + bottom: GyverLampSpacings.md, + left: GyverLampSpacings.sm, + ), + child: SizedBox( + height: GyverLampSpacings.xlg, + width: GyverLampSpacings.xlg + GyverLampSpacings.xs, + child: Align( + alignment: AlignmentDirectional.center, + child: _AnimatedValueText( + value: widget.value, + duration: _lastReportedItemIndex == _currentIndex + ? _kUpdateAnimationShortDuration + : _kUpdateAnimationDuration, + curve: _kUpdateAnimationCurve, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/segmented_selector.dart b/packages/gyver_lamp_ui/lib/src/widgets/segmented_selector.dart new file mode 100644 index 0000000..1b5f69d --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/segmented_selector.dart @@ -0,0 +1,903 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/rendering.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:tactile_feedback/tactile_feedback.dart'; + +const Duration _kScaleAnimationDuration = Duration(milliseconds: 500); +const Duration _kHighlightAnimationDuration = Duration(milliseconds: 200); +const Duration _kSpringAnimationDuration = Duration(milliseconds: 420); + +// The minimum scale factor of the thumb, when being pressed on for a sufficient +// amount of time. +const double _kMinThumbScale = 0.95; + +// The threshold value used in hasDraggedTooFar, for checking against the square +// L2 distance from the location of the current drag pointer, to the closest +// vertex of the SegmentedSelector's Rect. +const double _kTouchYDistanceThreshold = 50.0 * 50.0; + +// Width of the separator between segments. +const double _kSeparatorWidth = GyverLampSpacings.xs; + +// The corner radius of the thumb. +const Radius _kThumbRadius = Radius.circular(8); + +// The spring animation used when the thumb changes its rect. +final SpringSimulation _kThumbSpringAnimationSimulation = SpringSimulation( + const SpringDescription(mass: 1, stiffness: 503.551, damping: 44.8799), + 0, + 1, + 0, // Every time a new spring animation starts the previous animation stops. +); + +/// {@template selector_segment} +/// Data describing a segment of a [SegmentedSelector]. +/// {@endtemplate} +class SelectorSegment { + /// {@macro selector_segment} + const SelectorSegment({ + required this.value, + required this.label, + }); + + /// Value used to identify the segment. + /// + /// This value must be unique across all segments in a [SegmentedSelector]. + final T value; + + /// Label of the segment. + final String label; +} + +class _Segment extends StatefulWidget { + const _Segment({ + required ValueKey key, + required this.segment, + required this.pressed, + required this.highlighted, + required this.isDragging, + }) : super(key: key); + + final SelectorSegment segment; + + final bool pressed; + + final bool highlighted; + + /// Whether the thumb of the parent widget [SegmentedSelector] + /// is currently being dragged. + final bool isDragging; + + bool get shouldScaleContent => + (pressed && highlighted && isDragging) || (pressed && !highlighted); + + @override + _SegmentState createState() => _SegmentState(); +} + +class _SegmentState extends State<_Segment> + with TickerProviderStateMixin<_Segment> { + late final AnimationController _scaleController; + + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + + _scaleController = AnimationController( + duration: _kScaleAnimationDuration, + value: widget.shouldScaleContent ? 1 : 0, + vsync: this, + ); + + _scaleAnimation = _scaleController.drive( + Tween(begin: 1, end: 1), + ); + } + + @override + void didUpdateWidget(_Segment oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.shouldScaleContent != widget.shouldScaleContent) { + _scaleAnimation = _scaleController.drive( + Tween( + begin: _scaleAnimation.value, + end: widget.shouldScaleContent ? _kMinThumbScale : 1.0, + ), + ); + + _scaleController.animateWith(_kThumbSpringAnimationSimulation); + } + } + + @override + void dispose() { + _scaleController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return ScaleTransition( + scale: _scaleAnimation, + filterQuality: FilterQuality.low, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: GyverLampSpacings.sm, + horizontal: GyverLampSpacings.sm, + ), + child: AnimatedDefaultTextStyle( + style: GyverLampTextStyles.subtitle1.copyWith( + color: widget.highlighted ? theme.textPrimary : theme.textSecondary, + ), + duration: _kHighlightAnimationDuration, + curve: Curves.ease, + child: Text( + widget.segment.label, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + } +} + +/// {@template segmented_selector} +/// Gyver Lamp Segmented Selector. +/// {@endtemplate} +class SegmentedSelector extends StatefulWidget { + /// {@macro segmented_selector} + const SegmentedSelector({ + required this.segments, + required this.selected, + required this.onChanged, + super.key, + }) : assert( + segments.length >= 2, + 'Not enough segments. Please provide at least 2 segments.', + ); + + /// Descriptions of the segments in the selector. + final List> segments; + + /// The selected segment. + final T selected; + + /// The callback that is called when a new segment is selected. + final ValueChanged onChanged; + + @override + State> createState() => _SegmentedSelectorState(); +} + +class _SegmentedSelectorState + extends State> with TickerProviderStateMixin { + late final _thumbController = AnimationController( + duration: _kSpringAnimationDuration, + value: 0, + vsync: this, + ); + Animatable? _thumbAnimatable; + + late final _thumbScaleController = AnimationController( + duration: _kSpringAnimationDuration, + value: 0, + vsync: this, + ); + late Animation _thumbScaleAnimation = _thumbScaleController.drive( + Tween(begin: 1, end: _kMinThumbScale), + ); + + final _tap = TapGestureRecognizer(); + final _drag = HorizontalDragGestureRecognizer(); + final _longPress = LongPressGestureRecognizer(); + + // The segment the sliding thumb is currently located at, or animating to. It + // may have a different value from widget.groupValue, since this widget does + // not report a selection change via `onValueChanged` until the user stops + // interacting with the widget (onTapUp). For example, the user can drag the + // thumb around, and the `onValueChanged` callback will not be invoked until + // the thumb is let go. + late T _highlighted; + + // The segment the user is currently pressing. + T? _pressed; + + // Whether the current drag gesture started on a selected segment. When this + // flag is false, the `onUpdate` method does not update `highlighted`. + // Otherwise the thumb can be dragged around in an ongoing drag gesture. + bool? _startedOnSelectedSegment; + + // Whether an ongoing horizontal drag gesture that started on the thumb is + // present. When true, defer/ignore changes to the `highlighted` variable + // from other sources (except for semantics) until the gesture ends, + // preventing them from interfering with the active drag gesture. + bool get _isThumbDragging => _startedOnSelectedSegment ?? false; + + late T _feedbackValue = widget.selected; + + @override + void initState() { + super.initState(); + + _highlighted = widget.selected; + + // If the long press or horizontal drag recognizer gets accepted, we know + // for sure the gesture is meant for the segmented control. Hand everything + // to the drag gesture recognizer. + final team = GestureArenaTeam(); + _longPress.team = team; + _drag.team = team; + team.captain = _drag; + + _drag + ..onDown = _onDown + ..onUpdate = _onUpdate + ..onEnd = _onEnd + ..onCancel = _onCancel; + + _tap.onTapUp = _onTapUp; + + // Empty callback to enable the long press recognizer. + _longPress.onLongPress = () {}; + } + + @override + void didUpdateWidget(SegmentedSelector oldWidget) { + super.didUpdateWidget(oldWidget); + + // Temporarily ignore highlight changes from the widget when the thumb is + // being dragged. When the drag gesture finishes the widget will be forced + // to build (see the onEnd method), and didUpdateWidget will be called + // again. + if (!_isThumbDragging && _highlighted != widget.selected) { + _thumbController.animateWith(_kThumbSpringAnimationSimulation); + _thumbAnimatable = null; + _highlighted = widget.selected; + } + } + + @override + void dispose() { + _thumbScaleController.dispose(); + _thumbController.dispose(); + + _drag.dispose(); + _tap.dispose(); + _longPress.dispose(); + + super.dispose(); + } + + // Converts local coordinate to segments. This method assumes each segment has + // the same width. + T _segmentForXPosition(double dx) { + final renderBox = context.findRenderObject()! as RenderBox; + final numOfChildren = widget.segments.length; + + var index = (dx ~/ (renderBox.size.width / numOfChildren)) + .clamp(0, numOfChildren - 1); + + switch (Directionality.of(context)) { + case TextDirection.ltr: + break; + case TextDirection.rtl: + index = numOfChildren - 1 - index; + } + + return widget.segments.elementAt(index).value; + } + + bool _hasDraggedTooFar(DragUpdateDetails details) { + final renderBox = context.findRenderObject()! as RenderBox; + + final size = renderBox.size; + final offCenter = + details.localPosition - Offset(size.width / 2, size.height / 2); + final l2 = math.pow(math.max(0.0, offCenter.dx.abs() - size.width / 2), 2) + + math.pow(math.max(0.0, offCenter.dy.abs() - size.height / 2), 2) + as double; + + return l2 > _kTouchYDistanceThreshold; + } + + // The thumb shrinks when the user presses on it, and starts expanding when + // the user lets go. + // This animation must be synced with the segment scale animation (see the + // _Segment widget) to make the overall animation look natural when the thumb + // is not sliding. + void _playThumbScaleAnimation({required bool isExpanding}) { + _thumbScaleAnimation = _thumbScaleController.drive( + Tween( + begin: _thumbScaleAnimation.value, + end: isExpanding ? 1 : _kMinThumbScale, + ), + ); + _thumbScaleController.animateWith(_kThumbSpringAnimationSimulation); + } + + void _onHighlightChangedByGesture(T newValue) { + if (_highlighted == newValue) { + return; + } + + setState(() { + _highlighted = newValue; + }); + + _thumbController.animateWith(_kThumbSpringAnimationSimulation); + _thumbAnimatable = null; + } + + void _onPressedChangedByGesture(T? newValue) { + if (_pressed != newValue) { + setState(() { + _pressed = newValue; + }); + } + } + + void _onTapUp(TapUpDetails details) { + // No gesture should interfere with an ongoing thumb drag. + if (_isThumbDragging) { + return; + } + + final segment = _segmentForXPosition(details.localPosition.dx); + + _onPressedChangedByGesture(null); + + _handleFeedback(segment); + + if (segment != widget.selected) { + widget.onChanged(segment); + } + } + + void _onDown(DragDownDetails details) { + final touchDownSegment = _segmentForXPosition(details.localPosition.dx); + + _startedOnSelectedSegment = touchDownSegment == _highlighted; + + _onPressedChangedByGesture(touchDownSegment); + + _handleFeedback(touchDownSegment); + + if (_isThumbDragging) { + _playThumbScaleAnimation(isExpanding: false); + } + } + + void _onUpdate(DragUpdateDetails details) { + if (_isThumbDragging) { + final segment = _segmentForXPosition(details.localPosition.dx); + + _onPressedChangedByGesture(segment); + _onHighlightChangedByGesture(segment); + + _handleFeedback(segment); + } else { + final segment = _hasDraggedTooFar(details) + ? null + : _segmentForXPosition(details.localPosition.dx); + + _onPressedChangedByGesture(segment); + + _handleFeedback(segment ?? _feedbackValue); + } + } + + void _onEnd(DragEndDetails details) { + final pressed = this._pressed; + + if (_isThumbDragging) { + _playThumbScaleAnimation(isExpanding: true); + + if (_highlighted != widget.selected) { + widget.onChanged(_highlighted); + } + } else if (pressed != null) { + _onHighlightChangedByGesture(pressed); + + assert( + pressed == _highlighted, + 'pressed segment should be highlighted', + ); + + if (_highlighted != widget.selected) { + widget.onChanged(_highlighted); + } + } + + _onPressedChangedByGesture(null); + _startedOnSelectedSegment = null; + + _handleFeedback(_highlighted); + } + + void _onCancel() { + if (_isThumbDragging) { + _playThumbScaleAnimation(isExpanding: true); + } + + _onPressedChangedByGesture(null); + _startedOnSelectedSegment = null; + } + + void _handleFeedback(T value) { + if (_feedbackValue == value) { + return; + } + + _feedbackValue = value; + + TactileFeedback.impact(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + var children = widget.segments + .map( + (segment) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: _Segment( + key: ValueKey(segment.value), + segment: segment, + highlighted: _highlighted == segment.value, + pressed: _pressed == segment.value, + isDragging: _isThumbDragging, + ), + ); + }, + ) + .intersperse( + const SizedBox(width: _kSeparatorWidth), + ) + .toList(); + + var highlightedIndex = widget.segments.indexWhere( + (s) => s.value == _highlighted, + ); + + switch (Directionality.of(context)) { + case TextDirection.ltr: + break; + case TextDirection.rtl: + children = children.reversed.toList(); + highlightedIndex = widget.segments.length - highlightedIndex - 1; + } + + return UnconstrainedBox( + constrainedAxis: Axis.horizontal, + child: AnimatedBuilder( + animation: _thumbScaleAnimation, + builder: (BuildContext context, Widget? child) { + return _SegmentedSelectorRenderWidget( + highlightedIndex: highlightedIndex, + thumbColor: theme.surfaceSecondary, + thumbScale: _thumbScaleAnimation.value, + state: this, + children: children, + ); + }, + ), + ); + } +} + +class _SegmentedSelectorRenderWidget + extends MultiChildRenderObjectWidget { + const _SegmentedSelectorRenderWidget({ + required super.children, + required this.highlightedIndex, + required this.thumbColor, + required this.thumbScale, + required this.state, + super.key, + }); + + final int highlightedIndex; + final Color thumbColor; + final double thumbScale; + final _SegmentedSelectorState state; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderSegmentedSelector( + highlightedIndex: highlightedIndex, + thumbColor: thumbColor, + thumbScale: thumbScale, + state: state, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderSegmentedSelector renderObject, + ) { + renderObject + ..highlightedIndex = highlightedIndex + ..thumbColor = thumbColor + ..thumbScale = thumbScale; + } +} + +class _SegmentedSelectorParentData extends ContainerBoxParentData {} + +class _RenderSegmentedSelector extends RenderBox + with + ContainerRenderObjectMixin>, + RenderBoxContainerDefaultsMixin> { + _RenderSegmentedSelector({ + required int highlightedIndex, + required Color thumbColor, + required double thumbScale, + required this.state, + }) : _highlightedIndex = highlightedIndex, + _thumbColor = thumbColor, + _thumbScale = thumbScale; + + final _SegmentedSelectorState state; + + // The current **Unscaled** Thumb Rect in this RenderBox's coordinate space. + Rect? currentThumbRect; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + state._thumbController.addListener(markNeedsPaint); + } + + @override + void detach() { + state._thumbController.removeListener(markNeedsPaint); + super.detach(); + } + + @override + bool get isRepaintBoundary => true; + + int get highlightedIndex => _highlightedIndex; + int _highlightedIndex; + set highlightedIndex(int value) { + if (_highlightedIndex == value) { + return; + } + _highlightedIndex = value; + markNeedsPaint(); + } + + Color get thumbColor => _thumbColor; + Color _thumbColor; + set thumbColor(Color value) { + if (_thumbColor == value) { + return; + } + _thumbColor = value; + markNeedsPaint(); + } + + double get thumbScale => _thumbScale; + double _thumbScale; + set thumbScale(double value) { + if (_thumbScale == value) { + return; + } + _thumbScale = value; + markNeedsPaint(); + } + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + assert(debugHandleEvent(event, entry), ''); + + // No gesture should interfere with an ongoing thumb drag. + if (event is PointerDownEvent && !state._isThumbDragging) { + state._tap.addPointer(event); + state._longPress.addPointer(event); + state._drag.addPointer(event); + } + } + + // Intrinsic Dimensions + + double get totalSeparatorWidth => _kSeparatorWidth * (childCount ~/ 2); + + RenderBox? nonSeparatorChildAfter(RenderBox child) { + final nextChild = childAfter(child); + return nextChild == null ? null : childAfter(nextChild); + } + + @override + double computeMinIntrinsicWidth(double height) { + final childCount = this.childCount ~/ 2 + 1; + + var child = firstChild; + var maxMinChildWidth = 0.0; + + while (child != null) { + final childWidth = child.getMinIntrinsicWidth(height); + maxMinChildWidth = math.max(maxMinChildWidth, childWidth); + child = nonSeparatorChildAfter(child); + } + + return maxMinChildWidth * childCount + totalSeparatorWidth; + } + + @override + double computeMaxIntrinsicWidth(double height) { + final childCount = this.childCount ~/ 2 + 1; + + var child = firstChild; + var maxMaxChildWidth = 0.0; + + while (child != null) { + final childWidth = child.getMaxIntrinsicWidth(height); + maxMaxChildWidth = math.max(maxMaxChildWidth, childWidth); + child = nonSeparatorChildAfter(child); + } + + return maxMaxChildWidth * childCount + totalSeparatorWidth; + } + + @override + double computeMinIntrinsicHeight(double width) { + var child = firstChild; + var maxMinChildHeight = 0.0; + + while (child != null) { + final childHeight = child.getMinIntrinsicHeight(width); + maxMinChildHeight = math.max(maxMinChildHeight, childHeight); + child = nonSeparatorChildAfter(child); + } + + return maxMinChildHeight; + } + + @override + double computeMaxIntrinsicHeight(double width) { + var child = firstChild; + var maxMaxChildHeight = 0.0; + + while (child != null) { + final childHeight = child.getMaxIntrinsicHeight(width); + maxMaxChildHeight = math.max(maxMaxChildHeight, childHeight); + child = nonSeparatorChildAfter(child); + } + + return maxMaxChildHeight; + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _SegmentedSelectorParentData) { + child.parentData = _SegmentedSelectorParentData(); + } + } + + Size _calculateChildSize(BoxConstraints constraints) { + final childCount = this.childCount ~/ 2 + 1; + final separatorsCount = childCount - 1; + + var childWidth = + (constraints.minWidth - totalSeparatorWidth * separatorsCount) / + childCount; + var maxHeight = 0.0; + + var child = firstChild; + + while (child != null) { + childWidth = math.max( + childWidth, + child.getMaxIntrinsicWidth(double.infinity), + ); + + child = nonSeparatorChildAfter(child); + } + + childWidth = math.min( + childWidth, + (constraints.maxWidth - totalSeparatorWidth * separatorsCount) / + childCount, + ); + + child = firstChild; + + while (child != null) { + final boxHeight = child.getMaxIntrinsicHeight(childWidth); + maxHeight = math.max(maxHeight, boxHeight); + child = nonSeparatorChildAfter(child); + } + + return Size(childWidth, maxHeight); + } + + Size _computeOverallSizeFromChildSize( + Size childSize, + BoxConstraints constraints, + ) { + final childCount = this.childCount ~/ 2 + 1; + + return constraints.constrain( + Size( + childSize.width * childCount + totalSeparatorWidth, + childSize.height, + ), + ); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final childSize = _calculateChildSize(constraints); + return _computeOverallSizeFromChildSize(childSize, constraints); + } + + @override + void performLayout() { + final constraints = this.constraints; + final childSize = _calculateChildSize(constraints); + final childConstraints = BoxConstraints.tight(childSize); + final separatorConstraints = childConstraints.heightConstraints(); + + var child = firstChild; + var index = 0; + var start = 0.0; + + while (child != null) { + child.layout( + index.isEven ? childConstraints : separatorConstraints, + parentUsesSize: true, + ); + + final childParentData = child.parentData! as _SegmentedSelectorParentData; + final childOffset = Offset(start, 0); + + childParentData.offset = childOffset; + start += child.size.width; + + // coverage:ignore-start + assert( + index.isEven || child.size.width == _kSeparatorWidth, + '${child.size.width} != $_kSeparatorWidth', + ); + // coverage:ignore-end + + child = childAfter(child); + index += 1; + } + + size = _computeOverallSizeFromChildSize(childSize, constraints); + } + + // This method is used to convert the original unscaled thumb rect painted in + // the previous frame, to a Rect that is within the valid boundary defined by + // the child segments. + Rect? moveThumbRectInBound(Rect? thumbRect, List children) { + if (thumbRect == null) { + return null; + } + + final firstChildOffset = + (children.first.parentData! as _SegmentedSelectorParentData).offset; + final leftMost = firstChildOffset.dx; + final rightMost = + (children.last.parentData! as _SegmentedSelectorParentData).offset.dx + + children.last.size.width; + + // Ignore the horizontal position and the height of `thumbRect`, and + // calculate them from `children`. + return Rect.fromLTRB( + math.max(thumbRect.left, leftMost), + firstChildOffset.dy, + math.min(thumbRect.right, rightMost), + firstChildOffset.dy + children.first.size.height, + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + final children = getChildrenAsList(); + + // Paint separators. + for (var index = 1; index < childCount; index += 2) { + final child = children[index]; + final childParentData = child.parentData! as _SegmentedSelectorParentData; + context.paintChild(child, offset + childParentData.offset); + } + + // Paint thumb under the highlighted segment. + final selectedChild = children[highlightedIndex * 2]; + + final childParentData = + selectedChild.parentData! as _SegmentedSelectorParentData; + + final newThumbRect = childParentData.offset & selectedChild.size; + + // Update thumb animation's tween, in case the end rect changed (e.g., a + // new segment is added during the animation). + if (state._thumbController.isAnimating) { + final thumbTween = state._thumbAnimatable; + + if (thumbTween == null) { + // This is the first frame of the animation. + final startingRect = moveThumbRectInBound( + currentThumbRect, + children, + ) ?? + newThumbRect; + + state._thumbAnimatable = RectTween( + begin: startingRect, + end: newThumbRect, + ); + } + } else { + state._thumbAnimatable = null; + } + + final unscaledThumbRect = + state._thumbAnimatable?.evaluate(state._thumbController) ?? + newThumbRect; + + currentThumbRect = unscaledThumbRect; + + final thumbRRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: unscaledThumbRect.center, + width: unscaledThumbRect.width * thumbScale, + height: unscaledThumbRect.height * thumbScale, + ).shift(offset), + _kThumbRadius, + ); + + context.canvas.drawRRect( + thumbRRect, + Paint()..color = thumbColor, + ); + + // Paint segments. + for (var index = 0; index < children.length; index += 2) { + final child = children[index]; + final childParentData = child.parentData! as _SegmentedSelectorParentData; + context.paintChild(child, offset + childParentData.offset); + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + var child = lastChild; + + while (child != null) { + final childParentData = child.parentData! as _SegmentedSelectorParentData; + + if ((childParentData.offset & child.size).contains(position)) { + return result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset localOffset) { + return child!.hitTest(result, position: localOffset); + }, + ); + } + + child = childParentData.previousSibling; + } + + return false; + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/setting_tile.dart b/packages/gyver_lamp_ui/lib/src/widgets/setting_tile.dart new file mode 100644 index 0000000..5a9a234 --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/setting_tile.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +/// The minimal height of the tile. +const _kTileHeight = 56.0; + +/// The size of the icon. +const _kIconSize = 24.0; + +/// {@template setting_tile} +/// Gyver Lamp Setting Tile. +/// {@endtemplate} +class SettingTile extends StatelessWidget { + /// {@macro setting_tile} + const SettingTile({ + required this.icon, + required this.label, + required this.action, + super.key, + }); + + /// The icon of the tile. + final IconData icon; + + /// The label of the tile. + final String label; + + /// A widget to display after the label. + final Widget action; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: _kTileHeight), + child: ColoredBox( + color: theme.surfacePrimary, + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: GyverLampSpacings.lg, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: _kIconSize, + color: theme.textPrimary, + ), + const SizedBox(width: GyverLampSpacings.sm), + Flexible( + child: Text( + label, + style: GyverLampTextStyles.subtitle1.copyWith( + color: theme.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + action, + ], + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/setting_tile_group.dart b/packages/gyver_lamp_ui/lib/src/widgets/setting_tile_group.dart new file mode 100644 index 0000000..a9047db --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/setting_tile_group.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +// The corner radius of the group. +const _kBorderRadius = Radius.circular(8); + +/// {@template setting_tile_group} +/// Gyver Lamp Setting Tile Group. +/// {@endtemplate} +class SettingTileGroup extends StatelessWidget { + /// {@macro setting_tile_group} + const SettingTileGroup({ + required this.label, + required this.tiles, + super.key, + }); + + /// The label of the group. + final String label; + + /// The list of the tiles to group. + final List tiles; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return DecoratedBox( + decoration: ShapeDecoration( + color: theme.surfacePrimary, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(_kBorderRadius), + ), + shadows: [theme.shadows.shadow1], + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: GyverLampSpacings.sm, + horizontal: GyverLampSpacings.lg, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + label, + style: GyverLampTextStyles.body2.copyWith( + color: theme.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ...tiles.intersperse(const Divider()), + ], + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/switcher.dart b/packages/gyver_lamp_ui/lib/src/widgets/switcher.dart new file mode 100644 index 0000000..6908c0b --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/switcher.dart @@ -0,0 +1,579 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:tactile_feedback/tactile_feedback.dart'; + +// The height of the track. +const double _kTrackHeight = 32; + +// The width of the track. +const double _kTrackWidth = 52; + +// The radius of the thumb in idle state. +const double _kThumbRadius = 6; + +// The radius of the thumb in pressed state. +const double _kPressedThumbRadius = 8; + +// The radius of the splash when thumb is hovered, focused. +const double _kSplashRadius = 12; + +// The duration of the toggle animation. +const Duration _kToggleDuration = Duration(milliseconds: 150); + +// The minimum size of the switch. +const double _kSwitchMinSize = kMinInteractiveDimension - 8.0; + +// The height of the switch. +const double _kSwitchHeight = kMinInteractiveDimension; + +// The width of the switch. +const double _kSwitchWidth = _kTrackWidth - _kTrackHeight + _kSwitchMinSize; + +// The size of the switch. +const Size _kSwitchSize = Size(_kSwitchWidth, _kSwitchHeight); + +/// {@template switcher} +/// Gyver Lamp Switcher. +/// {@endtemplate} +class Switcher extends StatefulWidget { + /// {@macro switcher} + const Switcher({ + required this.value, + required this.onChanged, + super.key, + }); + + /// The value of the switcher. + final bool value; + + /// The callback that is called when the switcher value changes. + final ValueChanged onChanged; + + @override + State createState() => SwitcherState(); + + // coverage:ignore-start + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + FlagProperty( + 'value', + value: value, + ifTrue: 'on', + ifFalse: 'off', + showName: true, + ), + ); + } + // coverage:ignore-end +} + +/// Gyver Lamp Switcher state. +class SwitcherState extends State + with TickerProviderStateMixin, ToggleableStateMixin { + final _SwitchPainter _painter = _SwitchPainter(); + + bool _needsPositionAnimation = false; + + late bool _feedbackValue = widget.value; + + @override + void didUpdateWidget(Switcher oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.value != widget.value) { + // During a drag we may have modified the curve, reset it if its possible + // to do without visual discontinuation. + if (position.value == 0.0 || position.value == 1.0) { + position + ..curve = Curves.easeIn + ..reverseCurve = Curves.easeOut; + } + + animateToValue(); + } + } + + @override + void dispose() { + _painter.dispose(); + super.dispose(); + } + + @override + ValueChanged get onChanged => _handleChanged; + + @override + bool get tristate => false; + + @override + bool? get value => widget.value; + + double get _trackInnerLength => _kSwitchSize.width - _kSwitchMinSize; + + void _handleDragStart(DragStartDetails details) { + reactionController.forward(); + } + + void _handleDragUpdate(DragUpdateDetails details) { + position + ..curve = Curves.linear + ..reverseCurve = null; + + final delta = details.primaryDelta! / _trackInnerLength; + + switch (Directionality.of(context)) { + case TextDirection.rtl: + positionController.value -= delta; + case TextDirection.ltr: + positionController.value += delta; + } + + _handleFeedback(position.value >= 0.5); + } + + void _handleDragEnd(DragEndDetails details) { + if (position.value >= 0.5 != widget.value) { + widget.onChanged(!widget.value); + // Wait with finishing the animation until widget.value has changed to + // !widget.value as part of the widget.onChanged call above. + setState(() { + _needsPositionAnimation = true; + }); + } else { + animateToValue(); + } + + reactionController.reverse(); + } + + void _handleChanged(bool? value) { + assert( + value != null, + 'value can not be null', + ); + + widget.onChanged(value!); + _handleFeedback(value); + } + + void _handleFeedback(bool value) { + if (_feedbackValue == value) { + return; + } + + _feedbackValue = value; + + TactileFeedback.impact(); + } + + @override + Widget build(BuildContext context) { + if (_needsPositionAnimation) { + _needsPositionAnimation = false; + animateToValue(); + } + + positionController.duration = _kToggleDuration; + + final theme = Theme.of(context).extension()!; + final textDirection = Directionality.of(context); + + final splashColor = widget.value + ? theme.background.withOpacity(0.1) + : theme.textSecondary.withOpacity(0.1); + + return RepaintBoundary( + child: Semantics( + toggled: widget.value, + child: GestureDetector( + excludeFromSemantics: true, + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + child: buildToggleable( + size: _kSwitchSize, + mouseCursor: MaterialStateProperty.resolveWith( + (states) { + return MaterialStateProperty.resolveAs( + MaterialStateMouseCursor.clickable, + states, + ); + }, + ), + painter: _painter + ..trackHeight = _kTrackHeight + ..trackWidth = _kTrackWidth + ..trackInnerLength = _trackInnerLength + ..thumbRadius = _kThumbRadius + ..pressedThumbRadius = _kPressedThumbRadius + ..activeColor = theme.background + ..activePressedColor = theme.background + ..inactiveColor = theme.textSecondary + ..inactivePressedColor = theme.textSecondary + ..hoverColor = splashColor + ..focusColor = splashColor + ..splashRadius = _kSplashRadius + ..activeTrackColor = theme.onBackground + ..inactiveTrackColor = theme.surfaceVariant + ..inactiveReactionColor = Colors.transparent + ..reactionColor = Colors.transparent + ..isFocused = states.contains(MaterialState.focused) + ..isHovered = states.contains(MaterialState.hovered) + ..position = position + ..positionController = positionController + ..downPosition = downPosition + ..reaction = reaction + ..reactionFocusFade = reactionFocusFade + ..reactionHoverFade = reactionHoverFade + ..textDirection = textDirection, + ), + ), + ), + ); + } +} + +class _SwitchPainter extends ToggleablePainter { + AnimationController get positionController => _positionController!; + AnimationController? _positionController; + set positionController(AnimationController value) { + if (value == _positionController) { + return; + } + _positionController = value; + notifyListeners(); + } + + Color get activePressedColor => _activePressedColor!; + Color? _activePressedColor; + set activePressedColor(Color value) { + if (value == _activePressedColor) { + return; + } + _activePressedColor = value; + notifyListeners(); + } + + Color get inactivePressedColor => _inactivePressedColor!; + Color? _inactivePressedColor; + set inactivePressedColor(Color value) { + if (value == _inactivePressedColor) { + return; + } + _inactivePressedColor = value; + notifyListeners(); + } + + double get thumbRadius => _thumbRadius!; + double? _thumbRadius; + set thumbRadius(double value) { + if (value == _thumbRadius) { + return; + } + _thumbRadius = value; + notifyListeners(); + } + + double get pressedThumbRadius => _pressedThumbRadius!; + double? _pressedThumbRadius; + set pressedThumbRadius(double value) { + if (value == _pressedThumbRadius) { + return; + } + _pressedThumbRadius = value; + notifyListeners(); + } + + double get trackHeight => _trackHeight!; + double? _trackHeight; + set trackHeight(double value) { + if (value == _trackHeight) { + return; + } + _trackHeight = value; + notifyListeners(); + } + + double get trackWidth => _trackWidth!; + double? _trackWidth; + set trackWidth(double value) { + if (value == _trackWidth) { + return; + } + _trackWidth = value; + notifyListeners(); + } + + Color get activeTrackColor => _activeTrackColor!; + Color? _activeTrackColor; + set activeTrackColor(Color value) { + if (value == _activeTrackColor) { + return; + } + _activeTrackColor = value; + notifyListeners(); + } + + Color get inactiveTrackColor => _inactiveTrackColor!; + Color? _inactiveTrackColor; + set inactiveTrackColor(Color value) { + if (value == _inactiveTrackColor) { + return; + } + _inactiveTrackColor = value; + notifyListeners(); + } + + TextDirection get textDirection => _textDirection!; + TextDirection? _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) { + return; + } + _textDirection = value; + notifyListeners(); + } + + double get trackInnerLength => _trackInnerLength!; + double? _trackInnerLength; + set trackInnerLength(double value) { + if (value == _trackInnerLength) { + return; + } + _trackInnerLength = value; + notifyListeners(); + } + + bool _stopPressAnimation = false; + double? _pressedInactiveThumbRadius; + double? _pressedActiveThumbRadius; + + @override + void paint(Canvas canvas, Size size) { + final currentValue = position.value; + + final double visualPosition; + + switch (textDirection) { + case TextDirection.rtl: + visualPosition = 1.0 - currentValue; + case TextDirection.ltr: + visualPosition = currentValue; + } + + if (reaction.status == AnimationStatus.reverse && !_stopPressAnimation) { + _stopPressAnimation = true; + } else { + _stopPressAnimation = false; + } + + // To get the thumb radius when the press ends, the value can be any number + // between thumbRadius and pressedThumbRadius. + if (!_stopPressAnimation) { + if (currentValue == 0) { + _pressedInactiveThumbRadius = lerpDouble( + thumbRadius, + pressedThumbRadius, + reaction.value, + ); + _pressedActiveThumbRadius = thumbRadius; + } else if (currentValue == 1) { + _pressedActiveThumbRadius = lerpDouble( + thumbRadius, + pressedThumbRadius, + reaction.value, + ); + _pressedInactiveThumbRadius = thumbRadius; + } + } + + final inactiveThumbSize = Size.fromRadius( + _pressedInactiveThumbRadius ?? thumbRadius, + ); + final activeThumbSize = Size.fromRadius( + _pressedActiveThumbRadius ?? thumbRadius, + ); + + final Size thumbSize; + + if (reaction.isCompleted) { + thumbSize = Size.fromRadius(pressedThumbRadius); + } else { + thumbSize = Tween( + begin: inactiveThumbSize, + end: activeThumbSize, + ) + .chain(CurveTween(curve: Curves.easeInOut)) + .animate(positionController) + .value; + } + + final positionValue = CurvedAnimation( + parent: positionController, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + ).value; + + final trackColor = Color.lerp( + inactiveTrackColor, + activeTrackColor, + positionValue, + )!; + + final Color lerpedThumbColor; + + if (!reaction.isDismissed) { + lerpedThumbColor = Color.lerp( + inactivePressedColor, + activePressedColor, + positionValue, + )!; + } else if (positionController.status == AnimationStatus.forward) { + lerpedThumbColor = Color.lerp( + inactivePressedColor, + activeColor, + positionValue, + )!; + } else if (positionController.status == AnimationStatus.reverse) { + lerpedThumbColor = Color.lerp( + inactiveColor, + activePressedColor, + positionValue, + )!; + } else { + lerpedThumbColor = Color.lerp( + inactiveColor, + activeColor, + positionValue, + )!; + } + + final thumbColor = lerpedThumbColor; + + final trackPaintOffset = _computeTrackPaintOffset( + size, + trackWidth, + trackHeight, + ); + + final thumbPaintOffset = _computeThumbPaintOffset( + trackPaintOffset, + thumbSize, + visualPosition, + ); + + final radialReactionOrigin = Offset( + thumbPaintOffset.dx + thumbSize.height / 2, + size.height / 2, + ); + + _paintTrack( + canvas, + trackPaintOffset, + trackColor, + Size(trackWidth, trackHeight), + ); + + paintRadialReaction( + canvas: canvas, + origin: radialReactionOrigin, + ); + + _paintThumb( + canvas, + thumbPaintOffset, + thumbColor, + thumbSize, + ); + } + + /// Computes canvas offset for track's upper left corner. + Offset _computeTrackPaintOffset( + Size canvasSize, + double trackWidth, + double trackHeight, + ) { + final horizontalOffset = (canvasSize.width - trackWidth) / 2.0; + final verticalOffset = (canvasSize.height - trackHeight) / 2.0; + + return Offset(horizontalOffset, verticalOffset); + } + + /// Computes canvas offset for thumb's upper left corner. + Offset _computeThumbPaintOffset( + Offset trackPaintOffset, + Size thumbSize, + double visualPosition, + ) { + // How much thumb radius extends beyond the track. + final trackRadius = trackHeight / 2; + final additionalThumbRadius = thumbSize.height / 2 - trackRadius; + final additionalRectWidth = (thumbSize.width - thumbSize.height) / 2; + + final horizontalProgress = visualPosition * trackInnerLength; + final thumbHorizontalOffset = trackPaintOffset.dx - + additionalThumbRadius - + additionalRectWidth + + horizontalProgress; + final thumbVerticalOffset = trackPaintOffset.dy - additionalThumbRadius; + + return Offset(thumbHorizontalOffset, thumbVerticalOffset); + } + + void _paintTrack( + Canvas canvas, + Offset offset, + Color color, + Size size, + ) { + final trackRect = Rect.fromLTWH( + offset.dx, + offset.dy, + trackWidth, + trackHeight, + ); + + final trackRadius = trackHeight / 2; + + final trackRRect = RRect.fromRectAndRadius( + trackRect, + Radius.circular(trackRadius), + ); + + canvas.drawRRect( + trackRRect, + Paint()..color = color, + ); + } + + void _paintThumb( + Canvas canvas, + Offset offset, + Color color, + Size size, + ) { + final thumbRect = Rect.fromLTWH( + offset.dx, + offset.dy, + size.width, + size.height, + ); + + final thumbRadius = size.height / 2; + + final thumbRRect = RRect.fromRectAndRadius( + thumbRect, + Radius.circular(thumbRadius), + ); + + canvas.drawRRect( + thumbRRect, + Paint()..color = color, + ); + } +} diff --git a/packages/gyver_lamp_ui/lib/src/widgets/widgets.dart b/packages/gyver_lamp_ui/lib/src/widgets/widgets.dart new file mode 100644 index 0000000..6319c8c --- /dev/null +++ b/packages/gyver_lamp_ui/lib/src/widgets/widgets.dart @@ -0,0 +1,18 @@ +export 'alert_messenger.dart'; +export 'circles_wave_loading_indicator.dart'; +export 'confirmation_dialog.dart'; +export 'connection_status_badge.dart'; +export 'custom_app_bar.dart'; +export 'custom_dropdown_button.dart'; +export 'flat_icon_button.dart'; +export 'flat_text_button.dart'; +export 'gyver_lamp_dialog.dart'; +export 'gyver_lamp_gaps.dart'; +export 'labeled_input_field.dart'; +export 'rounded_elevated_button.dart'; +export 'rounded_outlined_button.dart'; +export 'ruler.dart'; +export 'segmented_selector.dart'; +export 'setting_tile.dart'; +export 'setting_tile_group.dart'; +export 'switcher.dart'; diff --git a/packages/gyver_lamp_ui/pubspec.yaml b/packages/gyver_lamp_ui/pubspec.yaml new file mode 100644 index 0000000..2fcd5c8 --- /dev/null +++ b/packages/gyver_lamp_ui/pubspec.yaml @@ -0,0 +1,34 @@ +name: gyver_lamp_ui +description: UI Toolkit for Gyver Lamp +version: 1.0.0 +publish_to: none + +environment: + sdk: '>=3.0.1 <4.0.0' + flutter: ">=3.10.0" + +dependencies: + flutter: + sdk: flutter + gap: ^3.0.1 + gyver_lamp_icons: + path: ../gyver_lamp_icons + meta: ^1.9.1 + tactile_feedback: + git: + url: https://github.com/ksokolovskyi/tactile_feedback + ref: 014245c2d2c2a0a888c37fc63a016141f10956c8 + +dev_dependencies: + flutter_test: + sdk: flutter + mockingjay: 0.4.0 + mocktail: 1.0.0 + plugin_platform_interface: 2.1.4 + very_good_analysis: 5.1.0 + +flutter: + fonts: + - family: Inter + fonts: + - asset: assets/fonts/Inter-Regular.ttf \ No newline at end of file diff --git a/packages/gyver_lamp_ui/test/src/extensions/intersperse_test.dart b/packages/gyver_lamp_ui/test/src/extensions/intersperse_test.dart new file mode 100644 index 0000000..4c1bc25 --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/extensions/intersperse_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('ChildrenIntersperse', () { + test( + 'inserts element between list of widgets', + () { + const a = SizedBox(width: 1); + const b = SizedBox(width: 2); + const c = SizedBox(width: 3); + const d = SizedBox(width: 4); + + final original = [a, b, c]; + + final interspersed = original.intersperse(d); + final expected = [a, d, b, d, c]; + + expect(interspersed.length, equals(expected.length)); + expect(interspersed, containsAllInOrder(expected)); + }, + ); + + test( + 'inserts nothing if array is empty', + () { + final original = []; + + final interspersed = original.intersperse(const SizedBox(width: 1)); + + expect(interspersed, isEmpty); + }, + ); + + test( + 'inserts nothing if array contains one element', + () { + const a = SizedBox(width: 1); + const d = SizedBox(width: 4); + + final original = [a]; + + final interspersed = original.intersperse(d); + + expect(interspersed.length, equals(1)); + expect(interspersed.first, equals(a)); + }, + ); + }); +} diff --git a/packages/gyver_lamp_ui/test/src/navigation/gyver_lamp_page_route_test.dart b/packages/gyver_lamp_ui/test/src/navigation/gyver_lamp_page_route_test.dart new file mode 100644 index 0000000..6dc3c82 --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/navigation/gyver_lamp_page_route_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('GyverLampPageRoute', () { + testWidgets('can be instantiated', (tester) async { + expect( + GyverLampPageRoute( + builder: (context) { + return const SizedBox.shrink(); + }, + ), + isNotNull, + ); + }); + + testWidgets('can be pushed', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return Center( + child: IconButton( + icon: const Icon(GyverLampIcons.arrow_right), + onPressed: () { + Navigator.of(context).push( + GyverLampPageRoute( + builder: (context) { + return Scaffold( + body: Center( + child: IconButton( + icon: const Icon(GyverLampIcons.arrow_left), + onPressed: () {}, + ), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ), + ), + ); + + expect(find.byIcon(GyverLampIcons.arrow_left), findsNothing); + + await tester.tap(find.byIcon(GyverLampIcons.arrow_right)); + await tester.pumpAndSettle(); + + expect(find.byIcon(GyverLampIcons.arrow_left), findsOneWidget); + }); + + testWidgets('can be popped', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return Center( + child: IconButton( + icon: const Icon(GyverLampIcons.arrow_right), + onPressed: () { + Navigator.of(context).push( + GyverLampPageRoute( + builder: (context) { + return Scaffold( + body: Center( + child: IconButton( + icon: const Icon(GyverLampIcons.arrow_left), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ), + ), + ); + + expect(find.byIcon(GyverLampIcons.arrow_left), findsNothing); + + await tester.tap(find.byIcon(GyverLampIcons.arrow_right)); + await tester.pumpAndSettle(); + + expect(find.byIcon(GyverLampIcons.arrow_left), findsOneWidget); + + await tester.tap(find.byIcon(GyverLampIcons.arrow_left)); + await tester.pumpAndSettle(); + + expect(find.byIcon(GyverLampIcons.arrow_left), findsNothing); + }); + }); +} diff --git a/packages/gyver_lamp_ui/test/src/theme/gyver_lamp_theme_test.dart b/packages/gyver_lamp_ui/test/src/theme/gyver_lamp_theme_test.dart new file mode 100644 index 0000000..2aeefc9 --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/theme/gyver_lamp_theme_test.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('GyverLampTheme', () { + group('lightThemeData', () { + testWidgets('can be instantiated', (tester) async { + expect( + GyverLampTheme.lightThemeData, + isNotNull, + ); + }); + + testWidgets('can be used', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: const Scaffold( + body: Column( + children: [ + LabeledInputField(label: 'Test'), + ], + ), + ), + ), + ); + }); + + testWidgets('provides GyverLampAppTheme extension', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: const Scaffold( + body: Column( + children: [ + LabeledInputField(label: 'Test'), + ], + ), + ), + ), + ); + + final theme = Theme.of( + tester.element(find.byType(Scaffold)), + ); + + final extension = theme.extension(); + + expect(extension, isNotNull); + expect( + extension!.background, + equals(GyverLampColors.lightBackground), + ); + }); + }); + + group('darkThemeData', () { + testWidgets('can be instantiated', (tester) async { + expect( + GyverLampTheme.darkThemeData, + isNotNull, + ); + }); + + testWidgets('can be used', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: GyverLampTheme.darkThemeData, + home: const Scaffold( + body: Column( + children: [ + LabeledInputField(label: 'Test'), + ], + ), + ), + ), + ); + }); + + testWidgets('provides GyverLampAppTheme extension', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: GyverLampTheme.darkThemeData, + home: const Scaffold( + body: Column( + children: [ + LabeledInputField(label: 'Test'), + ], + ), + ), + ), + ); + + final theme = Theme.of( + tester.element(find.byType(Scaffold)), + ); + + final extension = theme.extension(); + + expect(extension, isNotNull); + expect( + extension!.background, + equals(GyverLampColors.darkBackground), + ); + }); + }); + }); + + group('GyverLampAppTheme', () { + const light = GyverLampAppTheme( + background: GyverLampColors.lightBackground, + onBackground: GyverLampColors.lightOnBackground, + surfacePrimary: GyverLampColors.lightSurfacePrimary, + surfaceSecondary: GyverLampColors.lightSurfaceSecondary, + surfaceVariant: GyverLampColors.lightSurfaceVariant, + borderPrimary: GyverLampColors.lightBorderPrimary, + borderInput: GyverLampColors.lightBorderInput, + textPrimary: GyverLampColors.lightTextPrimary, + textSecondary: GyverLampColors.lightTextSecondary, + pointer: GyverLampColors.lightPointer, + connectedBackground: GyverLampColors.lightConnectedBackground, + connectedText: GyverLampColors.lightConnectedText, + connectingBackground: GyverLampColors.lightConnectingBackground, + connectingText: GyverLampColors.lightConnectingText, + notConnectedBackground: GyverLampColors.lightNotConnectedBackground, + notConnectedText: GyverLampColors.lightNotConnectedText, + divider: GyverLampColors.lightDivider, + buttonDisabled: GyverLampColors.lightButtonDisabled, + textButtonDisabled: GyverLampColors.lightTextButtonDisabled, + selectionBackground: GyverLampColors.lightSelectionBackground, + selectionHandle: GyverLampColors.lightSelectionHandle, + shadows: GyverLampShadows.light, + ); + + const dark = GyverLampAppTheme( + background: GyverLampColors.darkBackground, + onBackground: GyverLampColors.darkOnBackground, + surfacePrimary: GyverLampColors.darkSurfacePrimary, + surfaceSecondary: GyverLampColors.darkSurfaceSecondary, + surfaceVariant: GyverLampColors.darkSurfaceVariant, + borderPrimary: GyverLampColors.darkBorderPrimary, + borderInput: GyverLampColors.darkBorderInput, + textPrimary: GyverLampColors.darkTextPrimary, + textSecondary: GyverLampColors.darkTextSecondary, + pointer: GyverLampColors.darkPointer, + connectedBackground: GyverLampColors.darkConnectedBackground, + connectedText: GyverLampColors.darkConnectedText, + connectingBackground: GyverLampColors.darkConnectingBackground, + connectingText: GyverLampColors.darkConnectingText, + notConnectedBackground: GyverLampColors.darkNotConnectedBackground, + notConnectedText: GyverLampColors.darkNotConnectedText, + divider: GyverLampColors.darkDivider, + buttonDisabled: GyverLampColors.darkButtonDisabled, + textButtonDisabled: GyverLampColors.darkTextButtonDisabled, + selectionBackground: GyverLampColors.darkSelectionBackground, + selectionHandle: GyverLampColors.darkSelectionHandle, + shadows: GyverLampShadows.dark, + ); + + group('lerp', () { + test('returns same object when other is null', () { + final lerped = dark.lerp(null, 1); + + expect(lerped, equals(dark)); + }); + + test('returns new object with lerped properties', () { + final lerped = dark.lerp(light, 1); + + expect(lerped, isNot(equals(dark))); + expect(lerped.background, equals(light.background)); + }); + }); + + group('copyWith', () { + test( + 'returns new object with the same properties ' + 'when no properties are provided', + () { + final copy = dark.copyWith(); + + expect(copy, isNot(equals(dark))); + expect(copy.background, equals(dark.background)); + }, + ); + + test( + 'returns new object with updated passed properties', + () { + final copy = dark.copyWith(background: light.background); + + expect(copy, isNot(equals(dark))); + expect(copy.background, equals(light.background)); + }, + ); + }); + }); +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/alert_messenger_test.dart b/packages/gyver_lamp_ui/test/src/widgets/alert_messenger_test.dart new file mode 100644 index 0000000..f5f6912 --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/alert_messenger_test.dart @@ -0,0 +1,398 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('AlertMessenger', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject( + const AlertMessenger( + child: Scaffold(), + ), + ); + + expect( + find.byType(AlertMessenger), + findsOneWidget, + ); + expect( + find.byType(Scaffold), + findsOneWidget, + ); + }); + + testWidgets('updates child', (tester) async { + await tester.pumpSubject( + const AlertMessenger( + child: Scaffold(), + ), + ); + + expect( + find.byType(AlertMessenger), + findsOneWidget, + ); + expect( + find.byType(Scaffold), + findsOneWidget, + ); + expect( + find.byType(ColoredBox), + findsNothing, + ); + + await tester.pumpSubject( + const AlertMessenger( + child: ColoredBox( + color: Colors.red, + ), + ), + ); + + expect( + find.byType(AlertMessenger), + findsOneWidget, + ); + expect( + find.byType(Scaffold), + findsNothing, + ); + expect( + find.byType(ColoredBox), + findsOneWidget, + ); + }); + + testWidgets('AlertMessenger.of returns state', (tester) async { + await tester.pumpSubject( + const AlertMessenger( + child: Scaffold(), + ), + ); + + final state1 = tester.state( + find.byType(AlertMessenger), + ); + + final state2 = AlertMessenger.of( + tester.element( + find.byType(Scaffold), + ), + ); + + expect(state1, equals(state2)); + }); + + testWidgets( + 'AlertMessenger.of() throws when there is no AlertMessenger in the tree', + (tester) async { + await tester.pumpSubject( + const Scaffold(), + ); + + await expectLater( + () => AlertMessenger.of( + tester.element( + find.byType(Scaffold), + ), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains('No AlertMessenger widget found.'), + contains( + 'Scaffold widgets require an AlertMessenger ' + 'widget ancestor.', + ), + ), + ), + ), + ); + }, + ); + + testWidgets('showError() shows alert with animation', (tester) async { + await tester.pumpSubject( + const AlertMessenger( + child: Scaffold(), + ), + ); + + final state = AlertMessenger.of( + tester.element( + find.byType(Scaffold), + ), + ); + + var isShown = false; + + state.showInfo(message: 'ERROR').then((_) => isShown = true).ignore(); + + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isTrue); + + // Awaiting the animation. + await tester.pump(kShowDuration * 1.01); + + expect(find.text('ERROR'), findsOneWidget); + expect(isShown, isTrue); + }); + + testWidgets('showInfo() shows alert with animation', (tester) async { + await tester.pumpSubject( + const AlertMessenger( + child: Scaffold(), + ), + ); + + final state = AlertMessenger.of( + tester.element( + find.byType(Scaffold), + ), + ); + + var isShown = false; + + state.showInfo(message: 'INFO').then((_) => isShown = true).ignore(); + + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isTrue); + + // Awaiting the animation. + await tester.pump(kShowDuration * 1.01); + + expect(find.text('INFO'), findsOneWidget); + expect(isShown, isTrue); + }); + + testWidgets('hide() hides alert with animation', (tester) async { + await tester.runAsync( + () async { + await tester.pumpSubject( + const AlertMessenger( + child: Scaffold(), + ), + ); + + final state = AlertMessenger.of( + tester.element( + find.byType(Scaffold), + ), + ); + + state.showInfo(message: 'INFO').ignore(); + + // Awaiting the animation. + await tester.pump(kShowDuration); + + expect(find.text('INFO'), findsOneWidget); + + var isHidden = false; + + state.hide().then((_) => isHidden = true).ignore(); + + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isTrue); + + // Awaiting the animation. + await tester.pump(kHideDuration); + + expect(find.text('INFO'), findsNothing); + expect(isHidden, isTrue); + }, + ); + }); + + testWidgets('clear() hides alert without animation', (tester) async { + await tester.pumpSubject( + const AlertMessenger( + child: Scaffold(), + ), + ); + + final state = AlertMessenger.of( + tester.element( + find.byType(Scaffold), + ), + ); + + state.showInfo(message: 'INFO').ignore(); + + // Awaiting the animation. + await tester.pumpAndSettle(); + + expect(find.text('INFO'), findsOneWidget); + + state.clear(); + + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isFalse); + + expect(find.text('INFO'), findsNothing); + }); + + testWidgets('new alert replaces the old one', (tester) async { + await tester.pumpSubject( + const AlertMessenger( + child: Scaffold(), + ), + ); + + final state = AlertMessenger.of( + tester.element( + find.byType(Scaffold), + ), + ); + + state.showInfo(message: 'INFO').ignore(); + + // Awaiting the animation. + await tester.pumpAndSettle(); + + expect(find.text('INFO'), findsOneWidget); + + state.showInfo(message: 'NEW ONE').ignore(); + + // Awaiting the animation. + await tester.pumpAndSettle(); + + expect(find.text('INFO'), findsNothing); + expect(find.text('NEW ONE'), findsOneWidget); + }); + + testWidgets( + 'error alert closes automatically after the timeout', + (tester) async { + await tester.pumpSubject( + const AlertMessenger( + child: Scaffold(), + ), + ); + + final state = AlertMessenger.of( + tester.element( + find.byType(Scaffold), + ), + ); + + state.showError(message: 'ERROR').ignore(); + + // Awaiting the animation. + await tester.pumpAndSettle(); + + expect(find.text('ERROR'), findsOneWidget); + + // Awaiting the display duration. + await tester.pumpAndSettle(kErrorAlertDuration); + + expect(find.text('ERROR'), findsNothing); + }, + ); + + testWidgets( + 'info alert closes automatically after the timeout', + (tester) async { + await tester.pumpSubject( + const AlertMessenger( + child: Scaffold(), + ), + ); + + final state = AlertMessenger.of( + tester.element( + find.byType(Scaffold), + ), + ); + + state.showInfo(message: 'INFO').ignore(); + + // Awaiting the animation. + await tester.pumpAndSettle(); + + expect(find.text('INFO'), findsOneWidget); + + // Awaiting the display duration. + await tester.pumpAndSettle(kInfoAlertDuration); + + expect(find.text('INFO'), findsNothing); + }, + ); + + testWidgets('alert can be closed by button', (tester) async { + await tester.pumpSubject( + const AlertMessenger( + child: Scaffold(), + ), + ); + + final state = AlertMessenger.of( + tester.element( + find.byType(Scaffold), + ), + ); + + state.showInfo(message: 'INFO').ignore(); + + // Awaiting the animation. + await tester.pumpAndSettle(); + + expect(find.text('INFO'), findsOneWidget); + + await tester.tap( + find.byIcon(GyverLampIcons.close), + ); + + // Awaiting the animation. + await tester.pumpAndSettle(); + + expect(find.text('INFO'), findsNothing); + }); + + testWidgets('alert is removed after widget disposal', (tester) async { + await tester.pumpSubject( + const AlertMessenger( + child: Scaffold(), + ), + ); + + final state = AlertMessenger.of( + tester.element( + find.byType(Scaffold), + ), + ); + + state.showInfo(message: 'INFO').ignore(); + + // Awaiting the animation. + await tester.pumpAndSettle(); + + expect(find.text('INFO'), findsOneWidget); + + await tester.pumpSubject( + const Center(), + ); + + expect(find.text('INFO'), findsNothing); + }); + }); +} + +extension _AlertMessenger on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: child, + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/circles_wave_loading_indicator_test.dart b/packages/gyver_lamp_ui/test/src/widgets/circles_wave_loading_indicator_test.dart new file mode 100644 index 0000000..e2694dc --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/circles_wave_loading_indicator_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('CirclesWaveLoadingIndicator', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject( + const CirclesWaveLoadingIndicator(), + ); + + expect( + find.byType(CirclesWaveLoadingIndicator), + findsOneWidget, + ); + + expect( + find.descendant( + of: find.byType(Transform), + matching: find.byType(DecoratedBox), + ), + findsNWidgets(3), + ); + }); + + testWidgets('animates repeatedly', (tester) async { + await tester.pumpSubject( + const CirclesWaveLoadingIndicator(), + ); + + expect(tester.binding.hasScheduledFrame, isTrue); + + await tester.pump(const Duration(seconds: 5)); + + expect(tester.binding.hasScheduledFrame, isTrue); + }); + + testWidgets('renders color correctly', (tester) async { + await tester.pumpSubject( + const CirclesWaveLoadingIndicator(color: Colors.yellow), + ); + + expect( + tester.widgetList( + find.descendant( + of: find.byType(Transform), + matching: find.byType(DecoratedBox), + ), + ), + everyElement( + isA().having( + (b) => b.decoration, + 'decoration', + isA().having( + (d) => d.color, + 'color', + equals(Colors.yellow), + ), + ), + ), + ); + }); + + testWidgets('renders size correctly', (tester) async { + await tester.pumpSubject( + const CirclesWaveLoadingIndicator(size: 15), + ); + + expect( + tester.widgetList( + find.descendant( + of: find.byType(Transform), + matching: find.byType(SizedBox), + ), + ), + everyElement( + isA() + .having((b) => b.height, 'height', equals(15)) + .having((b) => b.width, 'width', equals(15)), + ), + ); + }); + }); +} + +extension _CirclesWaveLoadingIndicator on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/confirmation_dialog_test.dart b/packages/gyver_lamp_ui/test/src/widgets/confirmation_dialog_test.dart new file mode 100644 index 0000000..b6d4edb --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/confirmation_dialog_test.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; + +void main() { + group('ConfirmationDialog', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject( + ConfirmationDialog( + title: 'Title', + body: 'Body', + cancelLabel: 'Cancel', + confirmLabel: 'Confirm', + onCancel: () {}, + onConfirm: () {}, + ), + ); + + expect(find.byType(ConfirmationDialog), findsOneWidget); + expect(find.text('Title'), findsOneWidget); + expect(find.text('Body'), findsOneWidget); + expect( + find.ancestor( + of: find.text('Cancel'), + matching: find.byType(RoundedOutlinedButton), + ), + findsOneWidget, + ); + expect( + find.ancestor( + of: find.text('Confirm'), + matching: find.byType(RoundedElevatedButton), + ), + findsOneWidget, + ); + }); + + testWidgets('can be shown', (tester) async { + await tester.pumpSubject( + Builder( + builder: (context) { + return FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: () { + GyverLampDialog.show( + context, + dialog: ConfirmationDialog( + title: 'Title', + body: 'Body', + cancelLabel: 'Cancel', + confirmLabel: 'Confirm', + onCancel: () {}, + onConfirm: () {}, + ), + ); + }, + ); + }, + ), + ); + + await tester.tap(find.byType(FlatIconButton)); + await tester.pumpAndSettle(); + + expect(find.byType(ConfirmationDialog), findsOneWidget); + expect(find.text('Title'), findsOneWidget); + expect(find.text('Body'), findsOneWidget); + expect( + find.ancestor( + of: find.text('Cancel'), + matching: find.byType(RoundedOutlinedButton), + ), + findsOneWidget, + ); + expect( + find.ancestor( + of: find.text('Confirm'), + matching: find.byType(RoundedElevatedButton), + ), + findsOneWidget, + ); + }); + + testWidgets('calls onCancel after tap on cancel button', (tester) async { + var wasCancelled = false; + + await tester.pumpSubject( + ConfirmationDialog( + title: 'Title', + body: 'Body', + cancelLabel: 'Cancel', + confirmLabel: 'Confirm', + onCancel: () => wasCancelled = true, + onConfirm: () {}, + ), + ); + + await tester.tap( + find.ancestor( + of: find.text('Cancel'), + matching: find.byType(RoundedOutlinedButton), + ), + ); + + expect(wasCancelled, isTrue); + }); + + testWidgets('tries to pop page after tap on cancel button', (tester) async { + var wasPopped = false; + + final navigator = MockNavigator(); + + when(navigator.maybePop).thenAnswer((_) async { + wasPopped = true; + return true; + }); + + await tester.pumpSubject( + MockNavigatorProvider( + navigator: navigator, + child: ConfirmationDialog( + title: 'Title', + body: 'Body', + cancelLabel: 'Cancel', + confirmLabel: 'Confirm', + onCancel: () {}, + onConfirm: () {}, + ), + ), + ); + + await tester.tap( + find.ancestor( + of: find.text('Cancel'), + matching: find.byType(RoundedOutlinedButton), + ), + ); + + expect(wasPopped, isTrue); + }); + + testWidgets('calls onConfirm after tap on confirm button', (tester) async { + var wasConfirmed = false; + + await tester.pumpSubject( + ConfirmationDialog( + title: 'Title', + body: 'Body', + cancelLabel: 'Cancel', + confirmLabel: 'Confirm', + onCancel: () {}, + onConfirm: () => wasConfirmed = true, + ), + ); + + await tester.tap( + find.ancestor( + of: find.text('Confirm'), + matching: find.byType(RoundedElevatedButton), + ), + ); + + expect(wasConfirmed, isTrue); + }); + + testWidgets( + 'tries to pop page after tap on confirm button', + (tester) async { + var wasPopped = false; + + final navigator = MockNavigator(); + + when(navigator.maybePop).thenAnswer((_) async { + wasPopped = true; + return true; + }); + + await tester.pumpSubject( + MockNavigatorProvider( + navigator: navigator, + child: ConfirmationDialog( + title: 'Title', + body: 'Body', + cancelLabel: 'Cancel', + confirmLabel: 'Confirm', + onCancel: () {}, + onConfirm: () {}, + ), + ), + ); + + await tester.tap( + find.ancestor( + of: find.text('Confirm'), + matching: find.byType(RoundedElevatedButton), + ), + ); + + expect(wasPopped, isTrue); + }, + ); + }); +} + +extension _ConfirmationDialog on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/connection_status_badge_test.dart b/packages/gyver_lamp_ui/test/src/widgets/connection_status_badge_test.dart new file mode 100644 index 0000000..fa5590d --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/connection_status_badge_test.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + String label(ConnectionStatus status) => switch (status) { + (ConnectionStatus.connected) => 'Connected', + (ConnectionStatus.connecting) => 'Connecting', + (ConnectionStatus.notConnected) => 'Not Connected', + }; + + group('ConnectionStatusBadge', () { + testWidgets('renders correctly for connected status', (tester) async { + await tester.pumpSubject( + ConnectionStatusBadge( + status: ConnectionStatus.connected, + label: label, + onPressed: () {}, + ), + ); + + expect( + find.byType(ConnectionStatusBadge), + findsOneWidget, + ); + expect( + find.text(label(ConnectionStatus.connected)), + findsOneWidget, + ); + }); + + testWidgets('renders correctly for connecting status', (tester) async { + await tester.pumpSubject( + ConnectionStatusBadge( + status: ConnectionStatus.connecting, + label: label, + onPressed: () {}, + ), + ); + + expect( + find.byType(ConnectionStatusBadge), + findsOneWidget, + ); + expect( + find.text(label(ConnectionStatus.connecting)), + findsOneWidget, + ); + }); + + testWidgets('renders correctly for notConnected status', (tester) async { + await tester.pumpSubject( + ConnectionStatusBadge( + status: ConnectionStatus.notConnected, + label: label, + onPressed: () {}, + ), + ); + + expect( + find.byType(ConnectionStatusBadge), + findsOneWidget, + ); + expect( + find.text(label(ConnectionStatus.notConnected)), + findsOneWidget, + ); + }); + + testWidgets('animates on status change', (tester) async { + await tester.pumpSubject( + ConnectionStatusBadge( + status: ConnectionStatus.notConnected, + label: label, + onPressed: () {}, + ), + ); + + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isFalse); + expect( + find.text(label(ConnectionStatus.notConnected)), + findsOneWidget, + ); + + await tester.pumpSubject( + ConnectionStatusBadge( + status: ConnectionStatus.connected, + label: label, + onPressed: () {}, + ), + ); + + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isTrue); + + await tester.pumpAndSettle(); + + expect(tester.binding.hasScheduledFrame, isFalse); + expect( + find.text(label(ConnectionStatus.connected)), + findsOneWidget, + ); + }); + + testWidgets('calls onPressed after tap', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + ConnectionStatusBadge( + status: ConnectionStatus.notConnected, + label: label, + onPressed: () => wasTapped = true, + ), + ); + + await tester.tap(find.byType(ConnectionStatusBadge)); + + expect(wasTapped, isTrue); + }); + + testWidgets('cancels tap on drag', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + ConnectionStatusBadge( + status: ConnectionStatus.notConnected, + label: label, + onPressed: () => wasTapped = true, + ), + ); + + final state = tester.state( + find.byType(ConnectionStatusBadge), + ); + + await tester.drag( + find.byType(ConnectionStatusBadge), + const Offset(0, 200), + ); + + expect(wasTapped, isFalse); + expect(state.isPressed, isFalse); + }); + + testWidgets('animates tap when enabled', (tester) async { + await tester.pumpSubject( + ConnectionStatusBadge( + status: ConnectionStatus.notConnected, + label: label, + onPressed: () {}, + ), + ); + + await tester.pump(); + + final state = tester.state( + find.byType(ConnectionStatusBadge), + ); + + final gesture = await tester.press( + find.byType(ConnectionStatusBadge), + ); + + expect(tester.binding.hasScheduledFrame, isTrue); + expect(state.isPressed, isTrue); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(tester.binding.hasScheduledFrame, isFalse); + expect(state.isPressed, isFalse); + }); + + testWidgets('does not animate tap when disabled', (tester) async { + await tester.pumpSubject( + ConnectionStatusBadge( + status: ConnectionStatus.notConnected, + label: label, + onPressed: null, + ), + ); + + await tester.pump(); + + final state = tester.state( + find.byType(ConnectionStatusBadge), + ); + + await tester.press( + find.byType(ConnectionStatusBadge), + ); + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isFalse); + expect(state.isPressed, isFalse); + }); + }); +} + +extension _ConnectionStatusBadge on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/custom_app_bar_test.dart b/packages/gyver_lamp_ui/test/src/widgets/custom_app_bar_test.dart new file mode 100644 index 0000000..4bd95da --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/custom_app_bar_test.dart @@ -0,0 +1,307 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('CustomAppBar', () { + testWidgets('renders correctly with all parts specified', (tester) async { + await tester.pumpSubject( + CustomAppBar( + leading: FlatIconButton.medium( + icon: GyverLampIcons.align_left, + onPressed: () {}, + ), + title: 'Title', + actions: [ + Switcher( + value: false, + onChanged: (_) {}, + ), + ], + ), + ); + + expect(find.byType(CustomAppBar), findsOneWidget); + expect(find.byIcon(GyverLampIcons.align_left), findsOneWidget); + expect(find.text('Title'), findsOneWidget); + expect(find.byType(Switcher), findsOneWidget); + }); + + testWidgets('renders correctly with only leading', (tester) async { + await tester.pumpSubject( + CustomAppBar( + leading: FlatIconButton.medium( + icon: GyverLampIcons.align_left, + onPressed: () {}, + ), + ), + ); + + expect(find.byType(CustomAppBar), findsOneWidget); + expect(find.byIcon(GyverLampIcons.align_left), findsOneWidget); + }); + + testWidgets('renders correctly with only title specified', (tester) async { + await tester.pumpSubject( + const CustomAppBar( + title: 'Title', + ), + ); + + expect(find.byType(CustomAppBar), findsOneWidget); + expect(find.text('Title'), findsOneWidget); + }); + + testWidgets( + 'renders correctly with only actions specified', + (tester) async { + await tester.pumpSubject( + CustomAppBar( + actions: [ + FlatIconButton.medium( + icon: GyverLampIcons.align_left, + onPressed: () {}, + ), + Switcher( + value: false, + onChanged: (_) {}, + ), + ], + ), + ); + + expect(find.byType(CustomAppBar), findsOneWidget); + expect(find.byIcon(GyverLampIcons.align_left), findsOneWidget); + expect(find.byType(Switcher), findsOneWidget); + }, + ); + + testWidgets( + 'animates scrolling under for AxisDirection.down', + (tester) async { + await tester.pumpSubject( + Column( + children: [ + const CustomAppBar( + title: 'Title', + ), + Expanded( + child: ListView.builder( + itemCount: 100, + itemExtent: 20, + itemBuilder: (context, index) { + return ConstrainedBox( + key: ValueKey(index), + constraints: const BoxConstraints.expand(height: 20), + child: const ColoredBox(color: Colors.grey), + ); + }, + ), + ), + ], + ), + ); + + final state = tester.state( + find.byType(CustomAppBar), + ); + + expect(find.byType(CustomAppBar), findsOneWidget); + expect(find.byType(ListView), findsOneWidget); + expect(state.isScrolledUnder, isFalse); + + await tester.scrollUntilVisible( + find.byKey(const ValueKey(99)), + 200, + ); + await tester.pumpAndSettle(); + + expect(state.isScrolledUnder, isTrue); + + await tester.scrollUntilVisible( + find.byKey(const ValueKey(0)), + -200, + ); + await tester.pumpAndSettle(); + + expect(state.isScrolledUnder, isFalse); + }, + ); + + testWidgets( + 'animates scrolling under for AxisDirection.up', + (tester) async { + await tester.pumpSubject( + Column( + children: [ + const CustomAppBar( + title: 'Title', + ), + Expanded( + child: ListView.builder( + reverse: true, + itemCount: 100, + itemExtent: 20, + itemBuilder: (context, index) { + return ConstrainedBox( + key: ValueKey(index), + constraints: const BoxConstraints.expand(height: 20), + child: const ColoredBox(color: Colors.grey), + ); + }, + ), + ), + ], + ), + ); + + final state = tester.state( + find.byType(CustomAppBar), + ); + + expect(find.byType(CustomAppBar), findsOneWidget); + expect(find.byType(ListView), findsOneWidget); + expect(state.isScrolledUnder, isTrue); + + await tester.scrollUntilVisible( + find.byKey(const ValueKey(99)), + 200, + ); + await tester.pumpAndSettle(); + + expect(state.isScrolledUnder, isFalse); + + await tester.scrollUntilVisible( + find.byKey(const ValueKey(0)), + -200, + ); + await tester.pumpAndSettle(); + + expect(state.isScrolledUnder, isTrue); + }, + ); + + testWidgets( + 'does not animate scrolling under for AxisDirection.right', + (tester) async { + await tester.pumpSubject( + Column( + children: [ + const CustomAppBar( + title: 'Title', + ), + Expanded( + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 100, + itemExtent: 20, + itemBuilder: (context, index) { + return ConstrainedBox( + key: ValueKey(index), + constraints: const BoxConstraints.expand(width: 20), + child: const ColoredBox(color: Colors.grey), + ); + }, + ), + ), + ], + ), + ); + + final state = tester.state( + find.byType(CustomAppBar), + ); + + expect(find.byType(CustomAppBar), findsOneWidget); + expect(find.byType(ListView), findsOneWidget); + expect(state.isScrolledUnder, isFalse); + + await tester.scrollUntilVisible( + find.byKey(const ValueKey(99)), + 200, + ); + await tester.pumpAndSettle(); + + expect(state.isScrolledUnder, isFalse); + + await tester.scrollUntilVisible( + find.byKey(const ValueKey(0)), + -200, + ); + await tester.pumpAndSettle(); + + expect(state.isScrolledUnder, isFalse); + }, + ); + + testWidgets( + 'does not animate scrolling under for AxisDirection.left', + (tester) async { + await tester.pumpSubject( + Column( + children: [ + const CustomAppBar( + title: 'Title', + ), + Expanded( + child: ListView.builder( + scrollDirection: Axis.horizontal, + reverse: true, + itemCount: 100, + itemExtent: 20, + itemBuilder: (context, index) { + return ConstrainedBox( + key: ValueKey(index), + constraints: const BoxConstraints.expand(width: 20), + child: const ColoredBox(color: Colors.grey), + ); + }, + ), + ), + ], + ), + ); + + final state = tester.state( + find.byType(CustomAppBar), + ); + + expect(find.byType(CustomAppBar), findsOneWidget); + expect(find.byType(ListView), findsOneWidget); + expect(state.isScrolledUnder, isFalse); + + await tester.scrollUntilVisible( + find.byKey(const ValueKey(99)), + 200, + ); + await tester.pumpAndSettle(); + + expect(state.isScrolledUnder, isFalse); + + await tester.scrollUntilVisible( + find.byKey(const ValueKey(0)), + -200, + ); + await tester.pumpAndSettle(); + + expect(state.isScrolledUnder, isFalse); + }, + ); + }); +} + +extension _CustomAppBar on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center(child: child), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/custom_dropdown_button_test.dart b/packages/gyver_lamp_ui/test/src/widgets/custom_dropdown_button_test.dart new file mode 100644 index 0000000..9d43b3c --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/custom_dropdown_button_test.dart @@ -0,0 +1,479 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + const items = >[ + CustomDropdownMenuItem(value: 1, label: '1'), + CustomDropdownMenuItem(value: 2, label: '2'), + CustomDropdownMenuItem(value: 3, label: '3'), + CustomDropdownMenuItem(value: 4, label: '4'), + CustomDropdownMenuItem(value: 5, label: '5'), + CustomDropdownMenuItem(value: 6, label: '6'), + CustomDropdownMenuItem(value: 7, label: '7'), + CustomDropdownMenuItem(value: 8, label: '8'), + CustomDropdownMenuItem(value: 9, label: '9'), + CustomDropdownMenuItem(value: 10, label: '10'), + CustomDropdownMenuItem(value: 11, label: '11'), + CustomDropdownMenuItem(value: 12, label: '12'), + CustomDropdownMenuItem(value: 13, label: '13'), + ]; + + group('CustomDropdownButton', () { + testWidgets('renders correctly', (tester) async { + final selected = items[0]; + + await tester.pumpSubject( + Column( + children: [ + CustomDropdownButton( + items: items, + selected: selected.value, + onChanged: (_) {}, + ), + ], + ), + ); + + expect(find.byType(CustomDropdownButton), findsOneWidget); + expect(find.text(selected.label), findsOneWidget); + expect(find.byIcon(GyverLampIcons.chevron_down), findsOneWidget); + }); + + testWidgets('changes icon on tap', (tester) async { + await tester.pumpSubject( + Column( + children: [ + CustomDropdownButton( + items: items, + selected: items[0].value, + onChanged: (_) {}, + ), + ], + ), + ); + + expect(find.byIcon(GyverLampIcons.chevron_down), findsOneWidget); + expect(find.byIcon(GyverLampIcons.chevron_up), findsNothing); + + await tester.tap(find.byType(CustomDropdownButton)); + await tester.pumpAndSettle(); + + expect(find.byIcon(GyverLampIcons.chevron_down), findsNothing); + expect(find.byIcon(GyverLampIcons.chevron_up), findsOneWidget); + }); + + testWidgets('opens dropdown menu on tap', (tester) async { + await tester.pumpSubject( + Column( + children: [ + CustomDropdownButton( + items: items, + selected: items[0].value, + onChanged: (_) {}, + ), + ], + ), + ); + + await tester.tap(find.byType(CustomDropdownButton)); + await tester.pumpAndSettle(); + + expect(find.byType(CustomDropdownMenu), findsOneWidget); + }); + + testWidgets('closes dropdown menu on tap outside', (tester) async { + await tester.pumpSubject( + Column( + children: [ + CustomDropdownButton( + items: items, + selected: items[0].value, + onChanged: (_) {}, + ), + ], + ), + ); + + await tester.tap(find.byType(CustomDropdownButton)); + await tester.pumpAndSettle(); + + expect(find.byType(CustomDropdownMenu), findsOneWidget); + + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + expect(find.byType(CustomDropdownMenu), findsNothing); + }); + + testWidgets('closes dropdown menu on tap on menu item', (tester) async { + await tester.pumpSubject( + Column( + children: [ + CustomDropdownButton( + items: items, + selected: items[0].value, + onChanged: (_) {}, + ), + ], + ), + ); + + await tester.tap(find.byType(CustomDropdownButton)); + await tester.pumpAndSettle(); + + expect(find.byType(CustomDropdownMenu), findsOneWidget); + + await tester.tap(find.text(items[1].label)); + await tester.pumpAndSettle(); + + expect(find.byType(CustomDropdownMenu), findsNothing); + }); + + testWidgets('closes dropdown on orientation change', (tester) async { + final selected = items[0]; + + await tester.pumpSubject( + Builder( + builder: (context) { + final data = MediaQuery.of(context); + + return MediaQuery( + data: data, + child: Column( + children: [ + CustomDropdownButton( + items: items, + selected: selected.value, + onChanged: (_) {}, + ), + ], + ), + ); + }, + ), + ); + + final orientation = MediaQuery.of( + tester.element( + find.byType(CustomDropdownButton), + ), + ).orientation; + + await tester.tap(find.byType(CustomDropdownButton)); + await tester.pumpAndSettle(); + + expect(find.byType(CustomDropdownMenu), findsOneWidget); + + await tester.pumpSubject( + Builder( + builder: (context) { + final data = MediaQuery.of(context); + + return MediaQuery( + data: data.copyWith( + size: Size(data.size.height, data.size.width), + ), + child: Column( + children: [ + CustomDropdownButton( + items: items, + selected: selected.value, + onChanged: (_) {}, + ), + ], + ), + ); + }, + ), + ); + + await tester.pumpAndSettle(); + + final newOrientation = MediaQuery.of( + tester.element( + find.byType(CustomDropdownButton), + ), + ).orientation; + + expect(orientation, isNot(equals(newOrientation))); + expect(find.byType(CustomDropdownMenu), findsNothing); + }); + + testWidgets('calls onChanged when new value is selected', (tester) async { + int? newValue; + + await tester.pumpSubject( + Column( + children: [ + CustomDropdownButton( + items: items, + selected: items[0].value, + onChanged: (value) => newValue = value, + ), + ], + ), + ); + + await tester.tap(find.byType(CustomDropdownButton)); + await tester.pumpAndSettle(); + + await tester.tap(find.text(items[1].label)); + await tester.pumpAndSettle(); + + expect(find.byType(CustomDropdownMenu), findsNothing); + expect(newValue, isNotNull); + expect(newValue, equals(items[1].value)); + }); + + testWidgets( + 'does not call onChanged when same value is selected', + (tester) async { + int? newValue; + + final selected = items[0]; + + await tester.pumpSubject( + Column( + children: [ + CustomDropdownButton( + items: items, + selected: selected.value, + onChanged: (value) => newValue = value, + ), + ], + ), + ); + + await tester.tap(find.byType(CustomDropdownButton)); + await tester.pumpAndSettle(); + + await tester.tap( + find.descendant( + of: find.byType(CustomDropdownMenu), + matching: find.text(selected.label), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(CustomDropdownMenu), findsNothing); + expect(newValue, isNull); + }, + ); + + testWidgets('animates when new value is selected', (tester) async { + await tester.pumpSubject( + Column( + children: [ + CustomDropdownButton( + items: items, + selected: items[0].value, + onChanged: (_) {}, + ), + ], + ), + ); + + await tester.pumpAndSettle(); + + expect(tester.binding.hasScheduledFrame, isFalse); + + await tester.pumpSubject( + Column( + children: [ + CustomDropdownButton( + items: items, + selected: items[1].value, + onChanged: (_) {}, + ), + ], + ), + ); + + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isTrue); + + await tester.pumpAndSettle(); + + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('can be constrained by menuMaxHeight', (tester) async { + const menuMaxHeight = 200.0; + + await tester.pumpSubject( + Column( + children: [ + CustomDropdownButton( + menuMaxHeight: menuMaxHeight, + items: items, + selected: items[0].value, + onChanged: (_) {}, + ), + ], + ), + ); + + await tester.tap(find.byType(CustomDropdownButton)); + await tester.pumpAndSettle(); + + final size = tester.getSize(find.byType(CustomDropdownMenu)); + + expect(size.height, equals(menuMaxHeight)); + }); + + testWidgets( + 'menu width is equal to button width when textDirection is ltr', + (tester) async { + await tester.pumpSubject( + Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 30, right: 20), + child: CustomDropdownButton( + items: items, + selected: items[0].value, + onChanged: (_) {}, + ), + ), + ], + ), + ); + + await tester.tap(find.byType(CustomDropdownButton)); + await tester.pumpAndSettle(); + + final button = tester.getRect(find.byType(CustomDropdownButton)); + final menu = tester.getRect(find.byType(CustomDropdownMenu)); + + expect(button.left, equals(menu.left)); + expect(button.right, equals(menu.right)); + }, + ); + + testWidgets( + 'menu width is equal to button width when textDirection is rtl', + (tester) async { + await tester.pumpSubject( + Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 30, right: 20), + child: CustomDropdownButton( + items: items, + selected: items[0].value, + onChanged: (_) {}, + ), + ), + ], + ), + textDirection: TextDirection.rtl, + ); + + await tester.tap(find.byType(CustomDropdownButton)); + await tester.pumpAndSettle(); + + final button = tester.getRect(find.byType(CustomDropdownButton)); + final menu = tester.getRect(find.byType(CustomDropdownMenu)); + + expect(button.left, equals(menu.left)); + expect(button.right, equals(menu.right)); + }, + ); + + testWidgets('new item can be selected by arrows and enter', (tester) async { + int? newValue; + + final selected = items[0]; + + await tester.pumpSubject( + Column( + children: [ + CustomDropdownButton( + items: items, + selected: selected.value, + onChanged: (value) => newValue = value, + ), + ], + ), + ); + + await tester.tap(find.byType(CustomDropdownButton)); + await tester.pumpAndSettle(); + + expect(find.byType(CustomDropdownMenu), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.byType(CustomDropdownMenu), findsNothing); + + expect(newValue, isNotNull); + expect(newValue, isNot(equals(selected.value))); + }); + + testWidgets('new item can be selected by arrows and space', (tester) async { + int? newValue; + + final selected = items[0]; + + await tester.pumpSubject( + Column( + children: [ + CustomDropdownButton( + items: items, + selected: selected.value, + onChanged: (value) => newValue = value, + ), + ], + ), + ); + + await tester.tap(find.byType(CustomDropdownButton)); + await tester.pumpAndSettle(); + + expect(find.byType(CustomDropdownMenu), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + expect(find.byType(CustomDropdownMenu), findsNothing); + + expect(newValue, isNotNull); + expect(newValue, isNot(equals(selected.value))); + }); + }); +} + +extension _CustomDropdownButton on WidgetTester { + Future pumpSubject( + Widget child, { + TextDirection textDirection = TextDirection.ltr, + }) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: child, + ), + builder: (context, child) { + return Directionality( + textDirection: textDirection, + child: child!, + ); + }, + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/flat_icon_button_test.dart b/packages/gyver_lamp_ui/test/src/widgets/flat_icon_button_test.dart new file mode 100644 index 0000000..7c29f98 --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/flat_icon_button_test.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('FlatIconButton', () { + testWidgets( + 'sets correct size when created with FlatIconButton.medium', + (tester) async { + await tester.pumpSubject( + const FlatIconButton.medium( + icon: GyverLampIcons.close, + onPressed: null, + ), + ); + + expect( + tester.widget(find.byType(FlatIconButton)), + isA().having( + (b) => b.size, + 'size', + equals(FlatIconButtonSize.medium), + ), + ); + }, + ); + + testWidgets( + 'sets correct size when created with FlatIconButton.large', + (tester) async { + await tester.pumpSubject( + const FlatIconButton.large( + icon: GyverLampIcons.close, + onPressed: null, + ), + ); + + expect( + tester.widget(find.byType(FlatIconButton)), + isA().having( + (b) => b.size, + 'size', + equals(FlatIconButtonSize.large), + ), + ); + }, + ); + + testWidgets( + 'renders the given icon and responds to taps', + (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + FlatIconButton( + size: FlatIconButtonSize.medium, + icon: GyverLampIcons.close, + onPressed: () => wasTapped = true, + ), + ); + + await tester.tap( + find.byIcon(GyverLampIcons.close), + ); + + expect(wasTapped, isTrue); + }, + ); + + testWidgets('renders icon with half opacity when disabled', (tester) async { + await tester.pumpSubject( + const FlatIconButton( + size: FlatIconButtonSize.medium, + icon: GyverLampIcons.close, + onPressed: null, + ), + ); + + expect( + tester.widget(find.byIcon(GyverLampIcons.close)), + isA().having( + (i) => i.color, + 'color', + isA().having((c) => c.alpha, 'alpha', equals(128)), + ), + ); + }); + + testWidgets('renders sizes correctly in medium variant', (tester) async { + await tester.pumpSubject( + FlatIconButton( + size: FlatIconButtonSize.medium, + icon: GyverLampIcons.close, + onPressed: () {}, + ), + ); + + expect( + tester.widget(find.byType(FlatIconButton)), + isA() + .having((i) => i.dimension, 'dimension', equals(32)), + ); + + expect( + tester.widget(find.byIcon(GyverLampIcons.close)), + isA().having((i) => i.size, 'size', equals(24)), + ); + }); + + testWidgets('renders sizes correctly in large variant', (tester) async { + await tester.pumpSubject( + FlatIconButton( + size: FlatIconButtonSize.large, + icon: GyverLampIcons.close, + onPressed: () {}, + ), + ); + + expect( + tester.widget(find.byType(FlatIconButton)), + isA() + .having((i) => i.dimension, 'dimension', equals(44)), + ); + + expect( + tester.widget(find.byIcon(GyverLampIcons.close)), + isA().having((i) => i.size, 'size', equals(16)), + ); + }); + + testWidgets('calls onPressed after tap', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + FlatIconButton( + size: FlatIconButtonSize.medium, + icon: GyverLampIcons.close, + onPressed: () => wasTapped = true, + ), + ); + + await tester.tap(find.byType(FlatIconButton)); + + expect(wasTapped, isTrue); + }); + + testWidgets('cancels tap on drag', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + FlatIconButton( + size: FlatIconButtonSize.medium, + icon: GyverLampIcons.close, + onPressed: () => wasTapped = true, + ), + ); + + await tester.drag( + find.byIcon(GyverLampIcons.close), + const Offset(0, 100), + ); + + expect(wasTapped, isFalse); + }); + + testWidgets('animates tap when enabled', (tester) async { + await tester.pumpSubject( + FlatIconButton( + size: FlatIconButtonSize.medium, + icon: GyverLampIcons.close, + onPressed: () {}, + ), + ); + + await tester.tap(find.byIcon(GyverLampIcons.close)); + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isTrue); + }); + + testWidgets('does not animate tap when disabled', (tester) async { + await tester.pumpSubject( + const FlatIconButton( + size: FlatIconButtonSize.medium, + icon: GyverLampIcons.close, + onPressed: null, + ), + ); + + await tester.tap(find.byIcon(GyverLampIcons.close)); + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isFalse); + }); + }); +} + +extension _FlatIconButton on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/flat_text_button_test.dart b/packages/gyver_lamp_ui/test/src/widgets/flat_text_button_test.dart new file mode 100644 index 0000000..79ab277 --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/flat_text_button_test.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('FlatTextButton', () { + testWidgets( + 'sets correct size when created with FlatTextButton.small', + (tester) async { + await tester.pumpSubject( + const FlatTextButton.small( + onPressed: null, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(FlatTextButton)), + isA().having( + (b) => b.size, + 'size', + equals(FlatTextButtonSize.small), + ), + ); + }, + ); + + testWidgets( + 'sets correct size when created with FlatTextButton.medium', + (tester) async { + await tester.pumpSubject( + const FlatTextButton.medium( + onPressed: null, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(FlatTextButton)), + isA().having( + (b) => b.size, + 'size', + equals(FlatTextButtonSize.medium), + ), + ); + }, + ); + + testWidgets( + 'sets correct size when created with FlatTextButton.large', + (tester) async { + await tester.pumpSubject( + const FlatTextButton.large( + onPressed: null, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(FlatTextButton)), + isA().having( + (b) => b.size, + 'size', + equals(FlatTextButtonSize.large), + ), + ); + }, + ); + + testWidgets('renders the given text and responds to taps', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + FlatTextButton( + onPressed: () => wasTapped = true, + size: FlatTextButtonSize.small, + child: const Text('Text'), + ), + ); + + await tester.tap(find.text('Text')); + + expect(wasTapped, isTrue); + }); + + testWidgets('renders the given icon and responds to taps', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + FlatTextButton( + onPressed: () => wasTapped = true, + size: FlatTextButtonSize.small, + child: const Icon(GyverLampIcons.close), + ), + ); + + await tester.tap(find.byIcon(GyverLampIcons.close)); + + expect(wasTapped, isTrue); + }); + + testWidgets('renders text with half opacity when disabled', (tester) async { + await tester.pumpSubject( + const FlatTextButton( + onPressed: null, + size: FlatTextButtonSize.small, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RichText)), + isA().having( + (t) => t.text, + 'text', + isA() + .having( + (s) => s.text, + 'text', + equals('Text'), + ) + .having( + (s) => s.style?.color, + 'color', + isA().having((c) => c.alpha, 'alpha', equals(128)), + ), + ), + ); + }); + + testWidgets('renders sizes correctly in small variant', (tester) async { + await tester.pumpSubject( + const FlatTextButton( + onPressed: null, + size: FlatTextButtonSize.small, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(FlatTextButton)), + isA().having((b) => b.height, 'height', equals(32)), + ); + + expect( + tester.firstWidget( + find.ancestor( + of: find.text('Text'), + matching: find.byType(DefaultTextStyle), + ), + ), + isA().having( + (s) => s.style.fontSize, + 'fontSize', + equals(GyverLampTextStyles.buttonSmallBold.fontSize), + ), + ); + }); + + testWidgets('renders sizes correctly in medium variant', (tester) async { + await tester.pumpSubject( + const FlatTextButton( + onPressed: null, + size: FlatTextButtonSize.medium, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(FlatTextButton)), + isA().having((b) => b.height, 'height', equals(40)), + ); + + expect( + tester.firstWidget( + find.ancestor( + of: find.text('Text'), + matching: find.byType(DefaultTextStyle), + ), + ), + isA().having( + (s) => s.style.fontSize, + 'fontSize', + equals(GyverLampTextStyles.buttonMediumBold.fontSize), + ), + ); + }); + + testWidgets('renders sizes correctly in large variant', (tester) async { + await tester.pumpSubject( + const FlatTextButton( + onPressed: null, + size: FlatTextButtonSize.large, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(FlatTextButton)), + isA().having((b) => b.height, 'height', equals(48)), + ); + + expect( + tester.firstWidget( + find.ancestor( + of: find.text('Text'), + matching: find.byType(DefaultTextStyle), + ), + ), + isA().having( + (s) => s.style.fontSize, + 'fontSize', + equals(GyverLampTextStyles.buttonLargeBold.fontSize), + ), + ); + }); + + testWidgets('calls onPressed after tap', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + FlatTextButton( + onPressed: () => wasTapped = true, + size: FlatTextButtonSize.medium, + child: const Text('Text'), + ), + ); + + await tester.tap(find.byType(FlatTextButton)); + + expect(wasTapped, isTrue); + }); + + testWidgets('cancels tap on drag', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + FlatTextButton( + onPressed: () => wasTapped = true, + size: FlatTextButtonSize.medium, + child: const Text('Text'), + ), + ); + + await tester.drag( + find.text('Text'), + const Offset(0, 100), + ); + + expect(wasTapped, isFalse); + }); + + testWidgets('animates tap when enabled', (tester) async { + await tester.pumpSubject( + FlatTextButton( + onPressed: () {}, + size: FlatTextButtonSize.medium, + child: const Text('Text'), + ), + ); + + await tester.tap(find.text('Text')); + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isTrue); + }); + + testWidgets('does not animate tap when disabled', (tester) async { + await tester.pumpSubject( + const FlatTextButton( + onPressed: null, + size: FlatTextButtonSize.medium, + child: Text('Text'), + ), + ); + + await tester.tap(find.text('Text')); + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isFalse); + }); + }); +} + +extension _FlatTextButton on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/gyver_lamp_dialog_test.dart b/packages/gyver_lamp_ui/test/src/widgets/gyver_lamp_dialog_test.dart new file mode 100644 index 0000000..6ff9123 --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/gyver_lamp_dialog_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('GyverLampDialog', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject( + GyverLampDialog( + title: 'Title', + body: const Text('Body'), + actions: [ + FlatIconButton.medium( + icon: GyverLampIcons.close, + onPressed: () {}, + ), + ], + ), + ); + + expect(find.byType(GyverLampDialog), findsOneWidget); + expect(find.text('Title'), findsOneWidget); + expect(find.text('Body'), findsOneWidget); + expect(find.byIcon(GyverLampIcons.close), findsOneWidget); + }); + + group('show', () { + testWidgets('renders dialog widget', (tester) async { + await tester.pumpSubject( + Builder( + builder: (context) { + return FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: () { + GyverLampDialog.show( + context, + dialog: GyverLampDialog( + title: 'Title', + body: const Text('Body'), + actions: [ + FlatIconButton.medium( + icon: GyverLampIcons.close, + onPressed: () {}, + ), + ], + ), + ); + }, + ); + }, + ), + ); + + await tester.tap(find.byType(FlatIconButton)); + await tester.pumpAndSettle(); + + expect(find.byType(GyverLampDialog), findsOneWidget); + expect(find.text('Title'), findsOneWidget); + expect(find.text('Body'), findsOneWidget); + expect(find.byIcon(GyverLampIcons.close), findsOneWidget); + }); + }); + }); +} + +extension _GyverLampDialog on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/gyver_lamp_gaps_test.dart b/packages/gyver_lamp_ui/test/src/widgets/gyver_lamp_gaps_test.dart new file mode 100644 index 0000000..88a257a --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/gyver_lamp_gaps_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gap/gap.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('GyverLampGaps', () { + const gaps = [ + GyverLampGaps.xxxs, + GyverLampGaps.xxs, + GyverLampGaps.xs, + GyverLampGaps.sm, + GyverLampGaps.md, + GyverLampGaps.lg, + GyverLampGaps.xlgsm, + GyverLampGaps.xlg, + GyverLampGaps.xxlg, + GyverLampGaps.xxxlg, + ]; + + testWidgets('can be used in Row', (tester) async { + await tester.pumpSubject( + const Row( + mainAxisSize: MainAxisSize.min, + children: gaps, + ), + ); + + expect( + find.byType(Gap), + findsNWidgets(gaps.length), + ); + + final size = tester.getSize(find.byType(Row)); + + expect( + size.width, + equals( + gaps.fold(0, (sum, gap) => sum + gap.mainAxisExtent), + ), + ); + }); + + testWidgets('can be used in Column', (tester) async { + await tester.pumpSubject( + const Column( + mainAxisSize: MainAxisSize.min, + children: gaps, + ), + ); + + expect( + find.byType(Gap), + findsNWidgets(gaps.length), + ); + + final size = tester.getSize(find.byType(Column)); + + expect( + size.height, + equals( + gaps.fold(0, (sum, gap) => sum + gap.mainAxisExtent), + ), + ); + }); + }); +} + +extension _GyverLampGaps on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/labeled_input_field_test.dart b/packages/gyver_lamp_ui/test/src/widgets/labeled_input_field_test.dart new file mode 100644 index 0000000..4a57b7a --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/labeled_input_field_test.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('LabeledInputField', () { + testWidgets('renders correctly with light theme', (tester) async { + await tester.pumpSubject( + const LabeledInputField( + label: 'Input', + ), + ); + + expect( + find.byType(LabeledInputField), + findsOneWidget, + ); + expect( + find.text('Input'), + findsOneWidget, + ); + }); + + testWidgets('renders hintText correctly', (tester) async { + await tester.pumpSubject( + const LabeledInputField( + label: 'Input', + hintText: 'Hint', + ), + ); + + expect( + tester + .widget( + find.ancestor( + of: find.text('Hint'), + matching: find.byType(AnimatedOpacity), + ), + ) + .opacity, + equals(1.0), + ); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + expect( + tester + .widget( + find.ancestor( + of: find.text('Hint'), + matching: find.byType(AnimatedOpacity), + ), + ) + .opacity, + equals(1.0), + ); + + await tester.enterText( + find.byType(LabeledInputField), + '123', + ); + await tester.pumpAndSettle(); + + expect( + tester + .widget( + find.ancestor( + of: find.text('Hint'), + matching: find.byType(AnimatedOpacity), + ), + ) + .opacity, + equals(0.0), + ); + }); + + testWidgets('renders errorText correctly', (tester) async { + await tester.pumpSubject( + const LabeledInputField( + label: 'Input', + errorText: 'Error', + ), + ); + + expect( + find.text('Error'), + findsOneWidget, + ); + + await tester.pumpSubject( + const LabeledInputField( + label: 'Input', + ), + ); + + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isTrue); + + await tester.pumpAndSettle(); + + expect( + find.text('Error'), + findsNothing, + ); + }); + + testWidgets('calls onChanged after text editing', (tester) async { + var text = ''; + + await tester.pumpSubject( + LabeledInputField( + label: 'Input', + onChanged: (value) => text = value, + ), + ); + + await tester.enterText( + find.byType(LabeledInputField), + '123', + ); + + await tester.pumpAndSettle(); + + expect( + text, + equals('123'), + ); + }); + + testWidgets('can not be edited when disabled', (tester) async { + var text = ''; + + await tester.pumpSubject( + LabeledInputField( + label: 'Input', + enabled: false, + onChanged: (value) => text = value, + ), + ); + + await tester.enterText( + find.byType(LabeledInputField), + '123', + ); + + await tester.pumpAndSettle(); + + expect(text, isEmpty); + }); + + testWidgets('can be controlled by controller', (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpSubject( + LabeledInputField( + label: 'Input', + controller: controller, + ), + ); + + expect( + find.text('123'), + findsNothing, + ); + + controller.text = '123'; + + expect( + find.text('123'), + findsOneWidget, + ); + }); + + testWidgets('does not show clear icon when empty', (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpSubject( + LabeledInputField( + label: 'Input', + controller: controller, + ), + ); + + final opacity = tester + .widget( + find.ancestor( + of: find.byIcon(GyverLampIcons.close), + matching: find.byType(AnimatedOpacity), + ), + ) + .opacity; + + expect(opacity, equals(0)); + }); + + testWidgets('shows clear icon when not empty', (tester) async { + final controller = TextEditingController(text: 'test'); + addTearDown(controller.dispose); + + await tester.pumpSubject( + LabeledInputField( + label: 'Input', + controller: controller, + ), + ); + + final opacity = tester + .widget( + find.ancestor( + of: find.byIcon(GyverLampIcons.close), + matching: find.byType(AnimatedOpacity), + ), + ) + .opacity; + + expect(opacity, equals(1)); + }); + + testWidgets( + 'gets cleared and focused after clear button tap', + (tester) async { + final controller = TextEditingController(text: 'test'); + addTearDown(controller.dispose); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpSubject( + LabeledInputField( + label: 'Input', + controller: controller, + focusNode: focusNode, + ), + ); + + await tester.tap(find.byIcon(GyverLampIcons.close)); + await tester.pumpAndSettle(); + + expect(controller.text, isEmpty); + expect(focusNode.hasFocus, isTrue); + }, + ); + }); +} + +extension _LabeledInputField on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: Row( + children: [ + Expanded( + child: child, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/rounded_elevated_button_test.dart b/packages/gyver_lamp_ui/test/src/widgets/rounded_elevated_button_test.dart new file mode 100644 index 0000000..ba4ade3 --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/rounded_elevated_button_test.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('RoundedElevatedButton', () { + testWidgets( + 'sets correct size when created with RoundedElevatedButton.small', + (tester) async { + await tester.pumpSubject( + const RoundedElevatedButton.small( + onPressed: null, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RoundedElevatedButton)), + isA().having( + (b) => b.size, + 'size', + equals(RoundedElevatedButtonSize.small), + ), + ); + }, + ); + + testWidgets( + 'sets correct size when created with RoundedElevatedButton.medium', + (tester) async { + await tester.pumpSubject( + const RoundedElevatedButton.medium( + onPressed: null, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RoundedElevatedButton)), + isA().having( + (b) => b.size, + 'size', + equals(RoundedElevatedButtonSize.medium), + ), + ); + }, + ); + + testWidgets( + 'sets correct size when created with RoundedElevatedButton.large', + (tester) async { + await tester.pumpSubject( + const RoundedElevatedButton.large( + onPressed: null, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RoundedElevatedButton)), + isA().having( + (b) => b.size, + 'size', + equals(RoundedElevatedButtonSize.large), + ), + ); + }, + ); + + testWidgets('renders the given text and responds to taps', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + RoundedElevatedButton( + onPressed: () => wasTapped = true, + size: RoundedElevatedButtonSize.small, + child: const Text('Text'), + ), + ); + + await tester.tap(find.text('Text')); + + expect(wasTapped, isTrue); + }); + + testWidgets('renders the given icon and responds to taps', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + RoundedElevatedButton( + onPressed: () => wasTapped = true, + size: RoundedElevatedButtonSize.small, + child: const Icon(GyverLampIcons.close), + ), + ); + + await tester.tap(find.byIcon(GyverLampIcons.close)); + + expect(wasTapped, isTrue); + }); + + testWidgets( + 'renders text with textButtonDisabled color when disabled', + (tester) async { + await tester.pumpSubject( + const RoundedElevatedButton( + onPressed: null, + size: RoundedElevatedButtonSize.small, + child: Text('Text'), + ), + ); + + final context = tester.element(find.byType(RoundedElevatedButton)); + final theme = Theme.of(context).extension()!; + + expect( + tester.widget(find.byType(RichText)), + isA().having( + (t) => t.text, + 'text', + isA() + .having( + (s) => s.text, + 'text', + equals('Text'), + ) + .having( + (s) => s.style?.color, + 'color', + equals(theme.textButtonDisabled), + ), + ), + ); + }, + ); + + testWidgets('renders sizes correctly in small variant', (tester) async { + await tester.pumpSubject( + const RoundedElevatedButton( + onPressed: null, + size: RoundedElevatedButtonSize.small, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RoundedElevatedButton)), + isA().having( + (b) => b.height, + 'height', + equals(32), + ), + ); + + expect( + tester.firstWidget( + find.ancestor( + of: find.text('Text'), + matching: find.byType(DefaultTextStyle), + ), + ), + isA().having( + (s) => s.style.fontSize, + 'fontSize', + equals(GyverLampTextStyles.buttonSmallBold.fontSize), + ), + ); + }); + + testWidgets('renders sizes correctly in medium variant', (tester) async { + await tester.pumpSubject( + const RoundedElevatedButton( + onPressed: null, + size: RoundedElevatedButtonSize.medium, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RoundedElevatedButton)), + isA().having( + (b) => b.height, + 'height', + equals(40), + ), + ); + + expect( + tester.firstWidget( + find.ancestor( + of: find.text('Text'), + matching: find.byType(DefaultTextStyle), + ), + ), + isA().having( + (s) => s.style.fontSize, + 'fontSize', + equals(GyverLampTextStyles.buttonMediumBold.fontSize), + ), + ); + }); + + testWidgets('renders sizes correctly in large variant', (tester) async { + await tester.pumpSubject( + const RoundedElevatedButton( + onPressed: null, + size: RoundedElevatedButtonSize.large, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RoundedElevatedButton)), + isA().having( + (b) => b.height, + 'height', + equals(48), + ), + ); + + expect( + tester.firstWidget( + find.ancestor( + of: find.text('Text'), + matching: find.byType(DefaultTextStyle), + ), + ), + isA().having( + (s) => s.style.fontSize, + 'fontSize', + equals(GyverLampTextStyles.buttonLargeBold.fontSize), + ), + ); + }); + + testWidgets('calls onPressed after tap', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + RoundedElevatedButton( + onPressed: () => wasTapped = true, + size: RoundedElevatedButtonSize.medium, + child: const Text('Text'), + ), + ); + + await tester.tap(find.byType(RoundedElevatedButton)); + + expect(wasTapped, isTrue); + }); + + testWidgets('cancels tap on drag', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + RoundedElevatedButton( + onPressed: () => wasTapped = true, + size: RoundedElevatedButtonSize.medium, + child: const Text('Text'), + ), + ); + + final state = tester.state( + find.byType(RoundedElevatedButton), + ); + + await tester.drag( + find.text('Text'), + const Offset(0, 100), + ); + + expect(wasTapped, isFalse); + expect(state.isPressed, isFalse); + }); + + testWidgets('animates tap when enabled', (tester) async { + await tester.pumpSubject( + RoundedElevatedButton( + onPressed: () {}, + size: RoundedElevatedButtonSize.medium, + child: const Text('Text'), + ), + ); + + final state = tester.state( + find.byType(RoundedElevatedButton), + ); + + final gesture = await tester.press(find.text('Text')); + + expect(tester.binding.hasScheduledFrame, isTrue); + expect(state.isPressed, isTrue); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(tester.binding.hasScheduledFrame, isFalse); + expect(state.isPressed, isFalse); + }); + + testWidgets('does not animate tap when disabled', (tester) async { + await tester.pumpSubject( + const RoundedElevatedButton( + onPressed: null, + size: RoundedElevatedButtonSize.medium, + child: Text('Text'), + ), + ); + + final state = tester.state( + find.byType(RoundedElevatedButton), + ); + + await tester.press(find.text('Text')); + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isFalse); + expect(state.isPressed, isFalse); + }); + }); +} + +extension _RoundedElevatedButton on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/rounded_outlined_button_test.dart b/packages/gyver_lamp_ui/test/src/widgets/rounded_outlined_button_test.dart new file mode 100644 index 0000000..2e46f3e --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/rounded_outlined_button_test.dart @@ -0,0 +1,333 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('RoundedOutlinedButton', () { + testWidgets( + 'sets correct size when created with RoundedOutlinedButton.small', + (tester) async { + await tester.pumpSubject( + const RoundedOutlinedButton.small( + onPressed: null, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RoundedOutlinedButton)), + isA().having( + (b) => b.size, + 'size', + equals(RoundedOutlinedButtonSize.small), + ), + ); + }, + ); + + testWidgets( + 'sets correct size when created with RoundedOutlinedButton.medium', + (tester) async { + await tester.pumpSubject( + const RoundedOutlinedButton.medium( + onPressed: null, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RoundedOutlinedButton)), + isA().having( + (b) => b.size, + 'size', + equals(RoundedOutlinedButtonSize.medium), + ), + ); + }, + ); + + testWidgets( + 'sets correct size when created with RoundedOutlinedButton.large', + (tester) async { + await tester.pumpSubject( + const RoundedOutlinedButton.large( + onPressed: null, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RoundedOutlinedButton)), + isA().having( + (b) => b.size, + 'size', + equals(RoundedOutlinedButtonSize.large), + ), + ); + }, + ); + + testWidgets('renders the given text and responds to taps', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + RoundedOutlinedButton( + onPressed: () => wasTapped = true, + size: RoundedOutlinedButtonSize.small, + child: const Text('Text'), + ), + ); + + await tester.tap(find.text('Text')); + + expect(wasTapped, isTrue); + }); + + testWidgets('renders the given icon and responds to taps', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + RoundedOutlinedButton( + onPressed: () => wasTapped = true, + size: RoundedOutlinedButtonSize.small, + child: const Icon(GyverLampIcons.close), + ), + ); + + await tester.tap(find.byIcon(GyverLampIcons.close)); + + expect(wasTapped, isTrue); + }); + + testWidgets('renders text with half opacity when disabled', (tester) async { + await tester.pumpSubject( + const RoundedOutlinedButton( + onPressed: null, + size: RoundedOutlinedButtonSize.small, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RichText)), + isA().having( + (t) => t.text, + 'text', + isA() + .having( + (s) => s.text, + 'text', + equals('Text'), + ) + .having( + (s) => s.style?.color, + 'color', + isA().having((c) => c.alpha, 'alpha', equals(128)), + ), + ), + ); + }); + + testWidgets('renders sizes correctly in small variant', (tester) async { + await tester.pumpSubject( + const RoundedOutlinedButton( + onPressed: null, + size: RoundedOutlinedButtonSize.small, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RoundedOutlinedButton)), + isA().having( + (b) => b.height, + 'height', + equals(32), + ), + ); + + expect( + tester.firstWidget( + find.ancestor( + of: find.text('Text'), + matching: find.byType(DefaultTextStyle), + ), + ), + isA().having( + (s) => s.style.fontSize, + 'fontSize', + equals(GyverLampTextStyles.buttonSmallBold.fontSize), + ), + ); + }); + + testWidgets('renders sizes correctly in medium variant', (tester) async { + await tester.pumpSubject( + const RoundedOutlinedButton( + onPressed: null, + size: RoundedOutlinedButtonSize.medium, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RoundedOutlinedButton)), + isA().having( + (b) => b.height, + 'height', + equals(40), + ), + ); + + expect( + tester.firstWidget( + find.ancestor( + of: find.text('Text'), + matching: find.byType(DefaultTextStyle), + ), + ), + isA().having( + (s) => s.style.fontSize, + 'fontSize', + equals(GyverLampTextStyles.buttonMediumBold.fontSize), + ), + ); + }); + + testWidgets('renders sizes correctly in large variant', (tester) async { + await tester.pumpSubject( + const RoundedOutlinedButton( + onPressed: null, + size: RoundedOutlinedButtonSize.large, + child: Text('Text'), + ), + ); + + expect( + tester.widget(find.byType(RoundedOutlinedButton)), + isA().having( + (b) => b.height, + 'height', + equals(48), + ), + ); + + expect( + tester.firstWidget( + find.ancestor( + of: find.text('Text'), + matching: find.byType(DefaultTextStyle), + ), + ), + isA().having( + (s) => s.style.fontSize, + 'fontSize', + equals(GyverLampTextStyles.buttonLargeBold.fontSize), + ), + ); + }); + + testWidgets('calls onPressed after tap', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + RoundedOutlinedButton( + onPressed: () => wasTapped = true, + size: RoundedOutlinedButtonSize.medium, + child: const Text('Text'), + ), + ); + + await tester.tap(find.byType(RoundedOutlinedButton)); + + expect(wasTapped, isTrue); + }); + + testWidgets('cancels tap on drag', (tester) async { + var wasTapped = false; + + await tester.pumpSubject( + RoundedOutlinedButton( + onPressed: () => wasTapped = true, + size: RoundedOutlinedButtonSize.medium, + child: const Text('Text'), + ), + ); + + final state = tester.state( + find.byType(RoundedOutlinedButton), + ); + + await tester.drag( + find.text('Text'), + const Offset(0, 100), + ); + + expect(wasTapped, isFalse); + expect(state.isPressed, isFalse); + }); + + testWidgets('animates tap when enabled', (tester) async { + await tester.pumpSubject( + RoundedOutlinedButton( + onPressed: () {}, + size: RoundedOutlinedButtonSize.medium, + child: const Text('Text'), + ), + ); + + final state = tester.state( + find.byType(RoundedOutlinedButton), + ); + + final gesture = await tester.press(find.text('Text')); + + expect(tester.binding.hasScheduledFrame, isTrue); + expect(state.isPressed, isTrue); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(tester.binding.hasScheduledFrame, isFalse); + expect(state.isPressed, isFalse); + }); + + testWidgets('does not animate tap when disabled', (tester) async { + await tester.pumpSubject( + const RoundedOutlinedButton( + onPressed: null, + size: RoundedOutlinedButtonSize.medium, + child: Text('Text'), + ), + ); + + final state = tester.state( + find.byType(RoundedOutlinedButton), + ); + + await tester.press(find.text('Text')); + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isFalse); + expect(state.isPressed, isFalse); + }); + }); +} + +extension _RoundedOutlinedButton on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/ruler_test.dart b/packages/gyver_lamp_ui/test/src/widgets/ruler_test.dart new file mode 100644 index 0000000..2bd8fdc --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/ruler_test.dart @@ -0,0 +1,320 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:tactile_feedback/tactile_feedback_platform_interface.dart'; + +class _MockTactileFeedbackPlatform extends Mock + with MockPlatformInterfaceMixin + implements TactileFeedbackPlatform {} + +void main() { + late TactileFeedbackPlatform tactileFeedbackPlatform; + + setUp(() { + tactileFeedbackPlatform = _MockTactileFeedbackPlatform(); + + when(tactileFeedbackPlatform.impact).thenAnswer((_) async {}); + + TactileFeedbackPlatform.instance = tactileFeedbackPlatform; + }); + + group('Ruler', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject( + Ruler( + value: 1, + maxValue: 100, + onChanged: (value) {}, + ), + ); + + expect(find.byType(Ruler), findsOneWidget); + expect(find.text('1'), findsOneWidget); + }); + + testWidgets( + 'animates after new value provided to the widget', + (tester) async { + await tester.pumpSubject( + Ruler( + value: 1, + maxValue: 100, + onChanged: (value) {}, + ), + ); + + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isFalse); + + await tester.pumpSubject( + Ruler( + value: 3, + maxValue: 100, + onChanged: (value) {}, + ), + ); + + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isTrue); + + await tester.pumpAndSettle(); + + expect(tester.binding.hasScheduledFrame, isFalse); + + expect(find.text('3'), findsOneWidget); + }, + ); + + testWidgets( + 'does not call onChanged after new value provided to the widget', + (tester) async { + var wasCalled = false; + + await tester.pumpSubject( + Ruler( + value: 1, + maxValue: 100, + onChanged: (value) => wasCalled = true, + ), + ); + + await tester.pump(); + + await tester.pumpSubject( + Ruler( + value: 3, + maxValue: 100, + onChanged: (value) => wasCalled = true, + ), + ); + + await tester.pumpAndSettle(); + + expect(wasCalled, isFalse); + }, + ); + + testWidgets( + 'calls onChange for every mark hit during the drag', + (tester) async { + final valueNotifier = ValueNotifier(1); + + final changes = []; + + await tester.pumpSubject( + ValueListenableBuilder( + valueListenable: valueNotifier, + builder: (context, value, _) { + return Ruler( + value: value, + maxValue: 100, + onChanged: (value) { + valueNotifier.value = value; + changes.add(value); + }, + ); + }, + ), + ); + + await tester.dragNMarks(3); + + await tester.pumpAndSettle(); + + final expectedChanges = [2, 3, 4]; + + expect(changes.length, equals(expectedChanges.length)); + expect(changes, containsAllInOrder(expectedChanges)); + expect(find.text('4'), findsOneWidget); + }, + ); + + testWidgets( + 'correctly works with fling gesture', + (tester) async { + for (final platform in TargetPlatform.values) { + debugDefaultTargetPlatformOverride = platform; + + final changes = []; + + await tester.pumpSubject( + Builder( + builder: (context) { + return Theme( + data: Theme.of(context).copyWith(platform: platform), + child: Ruler( + key: ValueKey(platform), + value: 1, + maxValue: 100, + onChanged: changes.add, + ), + ); + }, + ), + ); + + await tester.fling( + find.byType(ListView), + const Offset(-400, 0), + 500, + ); + + await tester.pumpAndSettle(); + + debugDefaultTargetPlatformOverride = null; + + expect(changes, isNotEmpty, reason: platform.toString()); + } + }, + ); + + testWidgets( + 'correctly works with overscroll at the start', + (tester) async { + var wasCalled = false; + + await tester.pumpSubject( + Ruler( + value: 1, + maxValue: 100, + onChanged: (value) => wasCalled = true, + ), + ); + + await tester.timedDrag( + find.byType(ListView), + const Offset(300, 0), + const Duration(milliseconds: 500), + ); + + await tester.pumpAndSettle(); + + expect(wasCalled, isFalse); + }, + ); + + testWidgets( + 'correctly works with overscroll at the end', + (tester) async { + var wasCalled = false; + + await tester.pumpSubject( + Ruler( + value: 100, + maxValue: 100, + onChanged: (value) => wasCalled = true, + ), + ); + + await tester.timedDrag( + find.byType(ListView), + const Offset(-300, 0), + const Duration(milliseconds: 500), + ); + + await tester.pumpAndSettle(); + + expect(wasCalled, isFalse); + }, + ); + + testWidgets( + 'calls TactileFeedback.impact() when new value is provided to the widget', + (tester) async { + var wasImpacted = false; + + when(tactileFeedbackPlatform.impact).thenAnswer((_) async { + wasImpacted = true; + }); + + await tester.pumpSubject( + Ruler( + value: 1, + maxValue: 100, + onChanged: (value) {}, + ), + ); + + await tester.pump(); + + await tester.pumpSubject( + Ruler( + value: 2, + maxValue: 100, + onChanged: (value) {}, + ), + ); + + await tester.pumpAndSettle(); + + expect(wasImpacted, isTrue); + }, + ); + + testWidgets( + 'calls TactileFeedback.impact() when new value is selected by drag', + (tester) async { + var wasImpacted = false; + + when(tactileFeedbackPlatform.impact).thenAnswer((_) async { + wasImpacted = true; + }); + + await tester.pumpSubject( + Ruler( + value: 1, + maxValue: 100, + onChanged: (value) {}, + ), + ); + + await tester.pump(); + + await tester.dragNMarks(1); + + await tester.pumpAndSettle(); + + expect(wasImpacted, isTrue); + }, + ); + }); +} + +extension _Ruler on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Expanded(child: child), + ], + ), + ], + ), + ), + ), + ); + } + + Future dragNMarks(int n) async { + final offset = (kGapWidth + kMarkWidth) * n; + + await timedDrag( + find.byType(ListView), + Offset(-offset, 0), + const Duration(milliseconds: 1000), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/segmented_selector_test.dart b/packages/gyver_lamp_ui/test/src/widgets/segmented_selector_test.dart new file mode 100644 index 0000000..868c712 --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/segmented_selector_test.dart @@ -0,0 +1,654 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:tactile_feedback/tactile_feedback_platform_interface.dart'; + +class _MockTactileFeedbackPlatform extends Mock + with MockPlatformInterfaceMixin + implements TactileFeedbackPlatform {} + +void main() { + group('SegmentedSelector', () { + late TactileFeedbackPlatform tactileFeedbackPlatform; + + setUp(() { + tactileFeedbackPlatform = _MockTactileFeedbackPlatform(); + + when(tactileFeedbackPlatform.impact).thenAnswer((_) async {}); + + TactileFeedbackPlatform.instance = tactileFeedbackPlatform; + }); + + testWidgets('renders correctly with TextDirection.ltr', (tester) async { + await tester.pumpSubject( + Directionality( + textDirection: TextDirection.ltr, + child: SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) {}, + ), + ), + ); + + expect(find.byType(SegmentedSelector), findsOneWidget); + + final one = tester.getCenter(find.text('1')); + final two = tester.getCenter(find.text('2')); + final three = tester.getCenter(find.text('3')); + + expect(one.dx, lessThan(two.dx)); + expect(two.dx, lessThan(three.dx)); + }); + + testWidgets('renders correctly with TextDirection.rtl', (tester) async { + await tester.pumpSubject( + Directionality( + textDirection: TextDirection.rtl, + child: SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) {}, + ), + ), + ); + + expect(find.byType(SegmentedSelector), findsOneWidget); + + final one = tester.getCenter(find.text('1')); + final two = tester.getCenter(find.text('2')); + final three = tester.getCenter(find.text('3')); + + expect(one.dx, greaterThan(two.dx)); + expect(two.dx, greaterThan(three.dx)); + }); + + testWidgets( + 'calls onChanged when new value is selected by tap ' + 'with TextDirection.ltr', + (tester) async { + var newValue = -1; + + await tester.pumpSubject( + Directionality( + textDirection: TextDirection.ltr, + child: SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) => newValue = value, + ), + ), + ); + + await tester.tap(find.text('2')); + + expect(newValue, equals(2)); + }, + ); + + testWidgets( + 'calls onChanged when new value is selected by tap ' + 'with TextDirection.rtl', + (tester) async { + var newValue = -1; + + await tester.pumpSubject( + Directionality( + textDirection: TextDirection.rtl, + child: SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) => newValue = value, + ), + ), + ); + + await tester.tap(find.text('2')); + + expect(newValue, equals(2)); + }, + ); + + testWidgets( + 'calls onChanged when new value is selected by drag ' + 'with TextDirection.ltr', + (tester) async { + var newValue = -1; + + await tester.pumpSubject( + Directionality( + textDirection: TextDirection.ltr, + child: SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) => newValue = value, + ), + ), + ); + + await tester.pumpAndSettle(); + + await TestAsyncUtils.guard(() async { + final gesture = await tester.startGesture( + tester.getCenter(find.text('1')), + ); + + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('2'))); + + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('3'))); + + await tester.pumpAndSettle(); + + await gesture.up(); + + await tester.pumpAndSettle(); + }); + + expect(newValue, equals(3)); + }, + ); + + testWidgets( + 'calls onChanged when new value is selected by drag ' + 'with TextDirection.rtl', + (tester) async { + var newValue = -1; + + await tester.pumpSubject( + Directionality( + textDirection: TextDirection.rtl, + child: SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) => newValue = value, + ), + ), + ); + + await tester.pumpAndSettle(); + + await TestAsyncUtils.guard(() async { + final gesture = await tester.startGesture( + tester.getCenter(find.text('1')), + ); + + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('2'))); + + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('3'))); + + await tester.pumpAndSettle(); + + await gesture.up(); + + await tester.pumpAndSettle(); + }); + + expect(newValue, equals(3)); + }, + ); + + testWidgets( + 'calls onChanged when tapped on not selected segment ' + 'and dragged to another one', + (tester) async { + var newValue = -1; + + await tester.pumpSubject( + SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) => newValue = value, + ), + ); + + await tester.pumpAndSettle(); + + await TestAsyncUtils.guard(() async { + final gesture = await tester.startGesture( + tester.getCenter(find.text('2')), + ); + + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('3'))); + + await tester.pumpAndSettle(); + + await gesture.up(); + + await tester.pumpAndSettle(); + }); + + expect(newValue, equals(3)); + }, + ); + + testWidgets( + 'animates to the new value after widget selected value update', + (tester) async { + await tester.pumpSubject( + SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) {}, + ), + ); + + await tester.pump(); + + await tester.pumpSubject( + SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 2, + onChanged: (value) {}, + ), + ); + + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isTrue); + + await tester.pumpAndSettle(); + + expect(tester.binding.hasScheduledFrame, isFalse); + }, + ); + + testWidgets( + 'does not call onChanged when drag is cancelled', + (tester) async { + var wasCalled = false; + + await tester.pumpSubject( + SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) => wasCalled = true, + ), + ); + + await tester.pumpAndSettle(); + + await TestAsyncUtils.guard(() async { + final gesture = await tester.startGesture( + tester.getCenter(find.text('1')), + ); + + await gesture.cancel(); + }); + + expect(wasCalled, isFalse); + }, + ); + + testWidgets( + 'does not call onChanged when tapped on not selected segment ' + 'and dragged too far', + (tester) async { + var wasCalled = false; + + await tester.pumpSubject( + SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) => wasCalled = true, + ), + ); + + await tester.pumpAndSettle(); + + await TestAsyncUtils.guard(() async { + final gesture = await tester.startGesture( + tester.getCenter(find.text('3')), + ); + + await tester.pumpAndSettle(); + + await gesture.moveBy(const Offset(100, 0)); + + await tester.pumpAndSettle(); + + await gesture.up(); + + await tester.pumpAndSettle(); + }); + + expect(wasCalled, isFalse); + }, + ); + + testWidgets( + 'does not call onChanged after widget selected value update', + (tester) async { + var wasCalled = false; + + await tester.pumpSubject( + SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) => wasCalled = true, + ), + ); + + await tester.pump(); + + await tester.pumpSubject( + SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 2, + onChanged: (value) => wasCalled = true, + ), + ); + + await tester.pumpAndSettle(); + + expect(wasCalled, isFalse); + }, + ); + + testWidgets('animates segment on press', (tester) async { + await tester.pumpSubject( + SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) {}, + ), + ); + + await tester.pumpAndSettle(); + + await TestAsyncUtils.guard(() async { + final gesture = await tester.startGesture( + tester.getCenter(find.text('1')), + ); + + await tester.pump(const Duration(milliseconds: 100)); + + expect(tester.binding.hasScheduledFrame, isTrue); + + await tester.pumpAndSettle(); + + expect(tester.binding.hasScheduledFrame, isFalse); + + await gesture.up(); + + await tester.pump(const Duration(milliseconds: 100)); + + expect(tester.binding.hasScheduledFrame, isTrue); + + await tester.pumpAndSettle(); + + expect(tester.binding.hasScheduledFrame, isFalse); + }); + }); + + testWidgets('correctly computes dimensions', (tester) async { + late final BoxConstraints constraints; + + await tester.pumpSubject( + LayoutBuilder( + builder: (context, c) { + constraints = c; + + return SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) {}, + ); + }, + ), + ); + + final size = tester.getSize(find.byType(SegmentedSelector)); + + final renderBox = tester.renderObject( + find.byType(SegmentedSelector), + ) as RenderBox; + + expect(renderBox.getMinIntrinsicWidth(0), equals(size.width)); + expect(renderBox.getMaxIntrinsicWidth(0), equals(size.width)); + expect( + renderBox.getMinIntrinsicWidth(double.infinity), + equals(size.width), + ); + expect( + renderBox.getMaxIntrinsicWidth(double.infinity), + equals(size.width), + ); + + expect(renderBox.getMinIntrinsicHeight(0), equals(size.height)); + expect(renderBox.getMaxIntrinsicHeight(0), equals(size.height)); + expect( + renderBox.getMinIntrinsicHeight(double.infinity), + equals(size.height), + ); + expect( + renderBox.getMaxIntrinsicHeight(double.infinity), + equals(size.height), + ); + + expect( + renderBox.getDryLayout(constraints), + equals(size), + ); + }); + + testWidgets('updates after theme change', (tester) async { + tester.view + ..physicalSize = const Size(300, 300) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final repaintBoundaryKey = UniqueKey(); + + await tester.pumpSubject( + RepaintBoundary( + key: repaintBoundaryKey, + child: Theme( + data: GyverLampTheme.lightThemeData, + child: SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) {}, + ), + ), + ), + ); + + final renderRepaintBoundary = + tester.renderObject(find.byKey(repaintBoundaryKey)) + as RenderRepaintBoundary; + final imageBefore = renderRepaintBoundary.toImageSync(); + addTearDown(imageBefore.dispose); + + await tester.pumpSubject( + RepaintBoundary( + key: repaintBoundaryKey, + child: Theme( + data: GyverLampTheme.darkThemeData, + child: SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) {}, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect( + await matchesReferenceImage(imageBefore).matchAsync( + find.byKey(repaintBoundaryKey), + ), + contains('does not match'), + ); + }); + + testWidgets( + 'calls TactileFeedback.impact() when new value is selected by tap', + (tester) async { + var wasImpacted = false; + + when(tactileFeedbackPlatform.impact).thenAnswer((_) async { + wasImpacted = true; + }); + + await tester.pumpSubject( + SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) {}, + ), + ); + + await tester.tap(find.text('2')); + + expect(wasImpacted, isTrue); + }, + ); + + testWidgets( + 'calls onChanged when new value is selected by drag', + (tester) async { + var wasImpacted = false; + + when(tactileFeedbackPlatform.impact).thenAnswer((_) async { + wasImpacted = true; + }); + + await tester.pumpSubject( + SegmentedSelector( + segments: const [ + SelectorSegment(value: 1, label: '1'), + SelectorSegment(value: 2, label: '2'), + SelectorSegment(value: 3, label: '3'), + ], + selected: 1, + onChanged: (value) {}, + ), + ); + + await tester.pumpAndSettle(); + + await TestAsyncUtils.guard(() async { + final gesture = await tester.startGesture( + tester.getCenter(find.text('1')), + ); + + await tester.pumpAndSettle(); + + await gesture.moveTo(tester.getCenter(find.text('2'))); + + await tester.pumpAndSettle(); + + await gesture.up(); + + await tester.pumpAndSettle(); + }); + + expect(wasImpacted, isTrue); + }, + ); + }); +} + +extension _SegmentedSelector on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/setting_tile_group_test.dart b/packages/gyver_lamp_ui/test/src/widgets/setting_tile_group_test.dart new file mode 100644 index 0000000..2558b5c --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/setting_tile_group_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('SettingTileGroup', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject( + SettingTileGroup( + label: 'Group', + tiles: [ + SettingTile( + icon: GyverLampIcons.mail, + label: 'Mail', + action: FlatIconButton.medium( + icon: GyverLampIcons.done, + onPressed: () {}, + ), + ), + SettingTile( + icon: GyverLampIcons.github, + label: 'GitHub', + action: FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: () {}, + ), + ), + ], + ), + ); + + expect( + find.byType(SettingTileGroup), + findsOneWidget, + ); + expect( + find.byType(SettingTile), + findsNWidgets(2), + ); + expect( + find.byIcon(GyverLampIcons.mail), + findsOneWidget, + ); + expect( + find.text('Mail'), + findsOneWidget, + ); + expect( + find.byIcon(GyverLampIcons.done), + findsOneWidget, + ); + expect( + find.byType(Divider), + findsOneWidget, + ); + expect( + find.byIcon(GyverLampIcons.github), + findsOneWidget, + ); + expect( + find.text('GitHub'), + findsOneWidget, + ); + expect( + find.byIcon(GyverLampIcons.arrow_outward), + findsOneWidget, + ); + }); + }); +} + +extension _SettingTileGroup on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/setting_tile_test.dart b/packages/gyver_lamp_ui/test/src/widgets/setting_tile_test.dart new file mode 100644 index 0000000..e3c4bf5 --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/setting_tile_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +void main() { + group('SettingTile', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject( + SettingTile( + icon: GyverLampIcons.github, + label: 'GitHub', + action: FlatIconButton.medium( + icon: GyverLampIcons.arrow_outward, + onPressed: () {}, + ), + ), + ); + + expect( + find.byType(SettingTile), + findsOneWidget, + ); + expect( + find.byIcon(GyverLampIcons.github), + findsOneWidget, + ); + expect( + find.text('GitHub'), + findsOneWidget, + ); + expect( + find.byIcon(GyverLampIcons.arrow_outward), + findsOneWidget, + ); + }); + }); +} + +extension _SettingTile on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/gyver_lamp_ui/test/src/widgets/switcher_test.dart b/packages/gyver_lamp_ui/test/src/widgets/switcher_test.dart new file mode 100644 index 0000000..9d4cb08 --- /dev/null +++ b/packages/gyver_lamp_ui/test/src/widgets/switcher_test.dart @@ -0,0 +1,477 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:tactile_feedback/tactile_feedback_platform_interface.dart'; + +class _MockTactileFeedbackPlatform extends Mock + with MockPlatformInterfaceMixin + implements TactileFeedbackPlatform {} + +void main() { + group('Switcher', () { + late TactileFeedbackPlatform tactileFeedbackPlatform; + + setUp(() { + tactileFeedbackPlatform = _MockTactileFeedbackPlatform(); + + when(tactileFeedbackPlatform.impact).thenAnswer((_) async {}); + + TactileFeedbackPlatform.instance = tactileFeedbackPlatform; + }); + + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject( + Switcher( + value: true, + onChanged: (value) {}, + ), + ); + + final state = tester.state(find.byType(Switcher)); + + expect(find.byType(Switcher), findsOneWidget); + expect(state.value, isTrue); + }); + + testWidgets('calls onChanged when toggled on by drag', (tester) async { + var isToggled = false; + + await tester.pumpSubject( + StatefulBuilder( + builder: (context, setState) { + return Switcher( + value: isToggled, + onChanged: (value) { + setState( + () => isToggled = value, + ); + }, + ); + }, + ), + ); + + await tester.dragSwitcherForward(); + await tester.pumpAndSettle(); + + expect(isToggled, isTrue); + }); + + testWidgets('calls onChanged when toggled off by drag', (tester) async { + var isToggled = true; + + await tester.pumpSubject( + StatefulBuilder( + builder: (context, setState) { + return Switcher( + value: isToggled, + onChanged: (value) { + setState( + () => isToggled = value, + ); + }, + ); + }, + ), + ); + + await tester.dragSwitcherBackward(); + await tester.pumpAndSettle(); + + expect(isToggled, isFalse); + }); + + testWidgets( + 'calls onChanged when toggled on by drag and directionality is rtl', + (tester) async { + var isToggled = false; + + await tester.pumpSubject( + Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (context, setState) { + return Switcher( + value: isToggled, + onChanged: (value) { + setState( + () => isToggled = value, + ); + }, + ); + }, + ), + ), + ); + + await tester.dragSwitcherForward(textDirection: TextDirection.rtl); + await tester.pumpAndSettle(); + + expect(isToggled, isTrue); + }, + ); + + testWidgets( + 'calls onChanged when toggled off by drag and directionality is rtl', + (tester) async { + var isToggled = true; + + await tester.pumpSubject( + Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (context, setState) { + return Switcher( + value: isToggled, + onChanged: (value) { + setState( + () => isToggled = value, + ); + }, + ); + }, + ), + ), + ); + + await tester.dragSwitcherBackward(textDirection: TextDirection.rtl); + await tester.pumpAndSettle(); + + expect(isToggled, isFalse); + }, + ); + + testWidgets('calls onChanged when toggled on by tap', (tester) async { + var isToggled = false; + + await tester.pumpSubject( + StatefulBuilder( + builder: (context, setState) { + return Switcher( + value: isToggled, + onChanged: (value) { + setState( + () => isToggled = value, + ); + }, + ); + }, + ), + ); + + await tester.tap(find.byType(Switcher)); + await tester.pumpAndSettle(); + + expect(isToggled, isTrue); + }); + + testWidgets('calls onChanged when toggled off by tap', (tester) async { + var isToggled = true; + + await tester.pumpSubject( + StatefulBuilder( + builder: (context, setState) { + return Switcher( + value: isToggled, + onChanged: (value) { + setState( + () => isToggled = value, + ); + }, + ); + }, + ), + ); + + await tester.tap(find.byType(Switcher)); + await tester.pumpAndSettle(); + + expect(isToggled, isFalse); + }); + + testWidgets( + 'does not call onChanged when dragged slightly by mouse', + (tester) async { + var isToggled = false; + + await tester.pumpSubject( + StatefulBuilder( + builder: (context, setState) { + return Switcher( + value: isToggled, + onChanged: (value) { + setState( + () => isToggled = value, + ); + }, + ); + }, + ), + ); + + await tester.dragSwitcherForwardSlightlyByMouse(); + await tester.pumpAndSettle(); + + expect(isToggled, isFalse); + }, + ); + + testWidgets( + 'does not call onChanged after widget value update', + (tester) async { + var wasToggled = false; + + await tester.pumpSubject( + Switcher( + value: true, + onChanged: (value) => wasToggled = true, + ), + ); + + final state = tester.state(find.byType(Switcher)); + + expect(state.value, isTrue); + + await tester.pumpSubject( + Switcher( + value: false, + onChanged: (value) => wasToggled = true, + ), + ); + + expect(state.value, isFalse); + expect(wasToggled, isFalse); + }, + ); + + testWidgets('animates after widget value update', (tester) async { + await tester.pumpSubject( + Switcher( + value: true, + onChanged: (value) {}, + ), + ); + + await tester.pump(); + + final state = tester.state(find.byType(Switcher)); + + expect(state.value, isTrue); + expect(tester.binding.hasScheduledFrame, isFalse); + + await tester.pumpSubject( + Switcher( + value: false, + onChanged: (value) {}, + ), + ); + + expect(state.value, isFalse); + expect(tester.binding.hasScheduledFrame, isTrue); + + await tester.pumpAndSettle(); + + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('animates back when update value is not passed into the widget', + (tester) async { + var wasToggled = false; + + await tester.pumpSubject( + Switcher( + value: false, + onChanged: (value) { + wasToggled = true; + }, + ), + ); + + await tester.pump(); + + final state = tester.state(find.byType(Switcher)); + + expect(state.value, isFalse); + expect(tester.binding.hasScheduledFrame, isFalse); + + await tester.tap(find.byType(Switcher)); + + expect(wasToggled, isTrue); + expect(state.value, isFalse); + + expect(tester.binding.hasScheduledFrame, isTrue); + + await tester.pumpAndSettle(); + + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('animates thumb on press', (tester) async { + await tester.pumpSubject( + Switcher( + value: false, + onChanged: (value) {}, + ), + ); + + await tester.pumpAndSettle(); + + final finder = find.byType(Switcher); + + final width = tester.getSize(finder).width; + final offset = -width / 2 + width / 4; + final left = tester.getCenter(finder).translate(offset, 0); + + final state = tester.state(finder); + + await TestAsyncUtils.guard(() async { + final gesture = await tester.startGesture(left); + + await tester.pumpAndSettle(); + + expect(state.reactionController.isCompleted, isTrue); + + await gesture.up(); + + await tester.pumpAndSettle(); + + expect(state.reactionController.isDismissed, isTrue); + }); + }); + + testWidgets( + 'calls TactileFeedback.impact() when toggled on by drag', + (tester) async { + var wasImpacted = false; + + when(tactileFeedbackPlatform.impact).thenAnswer((_) async { + wasImpacted = true; + }); + + var isToggled = false; + + await tester.pumpSubject( + StatefulBuilder( + builder: (context, setState) { + return Switcher( + value: isToggled, + onChanged: (value) { + setState( + () => isToggled = value, + ); + }, + ); + }, + ), + ); + + await tester.dragSwitcherForward(); + await tester.pumpAndSettle(); + + expect(isToggled, isTrue); + expect(wasImpacted, isTrue); + }, + ); + + testWidgets( + 'calls TactileFeedback.impact() when toggled on by tap', + (tester) async { + var wasImpacted = false; + + when(tactileFeedbackPlatform.impact).thenAnswer((_) async { + wasImpacted = true; + }); + + var isToggled = false; + + await tester.pumpSubject( + StatefulBuilder( + builder: (context, setState) { + return Switcher( + value: isToggled, + onChanged: (value) { + setState( + () => isToggled = value, + ); + }, + ); + }, + ), + ); + + await tester.tap(find.byType(Switcher)); + await tester.pumpAndSettle(); + + expect(isToggled, isTrue); + expect(wasImpacted, isTrue); + }, + ); + }); +} + +extension _Switcher on WidgetTester { + Future pumpSubject( + Widget child, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center( + child: child, + ), + ), + ), + ); + } + + Future dragSwitcherForwardSlightlyByMouse() async { + final finder = find.byType(Switcher); + + final width = getSize(finder).width; + final offset = -width / 2 + width / 6; + final left = getCenter(finder).translate(offset, 0); + + return dragFrom( + left, + Offset(width / 4, 0), + kind: PointerDeviceKind.mouse, + ); + } + + Future dragSwitcherForward({ + TextDirection textDirection = TextDirection.ltr, + }) async { + if (textDirection != TextDirection.ltr) { + return dragSwitcherBackward(); + } + + final finder = find.byType(Switcher); + + final width = getSize(finder).width; + final offset = -width / 2 + width / 6; + final left = getCenter(finder).translate(offset, 0); + + return dragFrom(left, Offset(width, 0)); + } + + Future dragSwitcherBackward({ + TextDirection textDirection = TextDirection.ltr, + }) async { + if (textDirection != TextDirection.ltr) { + return dragSwitcherForward(); + } + + final finder = find.byType(Switcher); + + final width = getSize(finder).width; + final offset = width / 2 - width / 6; + final right = getCenter(finder).translate(offset, 0); + + return dragFrom(right, Offset(-width, 0)); + } +} diff --git a/packages/settings_controller/.gitignore b/packages/settings_controller/.gitignore new file mode 100644 index 0000000..4fe5c69 --- /dev/null +++ b/packages/settings_controller/.gitignore @@ -0,0 +1,15 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock + +# 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/ + +# Coverage +coverage/ diff --git a/packages/settings_controller/README.md b/packages/settings_controller/README.md new file mode 100644 index 0000000..ff6b78c --- /dev/null +++ b/packages/settings_controller/README.md @@ -0,0 +1,13 @@ +# Settings Controller + +[![coverage][coverage_badge]][coverage_badge] +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +Controller to manage settings. + +[coverage_badge]: https://raw.githubusercontent.com/VeryGoodOpenSource/very_good_cli/main/coverage_badge.svg +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT \ No newline at end of file diff --git a/packages/settings_controller/analysis_options.yaml b/packages/settings_controller/analysis_options.yaml new file mode 100644 index 0000000..670d939 --- /dev/null +++ b/packages/settings_controller/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/packages/settings_controller/coverage_badge.svg b/packages/settings_controller/coverage_badge.svg new file mode 100644 index 0000000..499e98c --- /dev/null +++ b/packages/settings_controller/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/settings_controller/lib/settings_controller.dart b/packages/settings_controller/lib/settings_controller.dart new file mode 100644 index 0000000..c45deb1 --- /dev/null +++ b/packages/settings_controller/lib/settings_controller.dart @@ -0,0 +1,5 @@ +/// Controller to manage settings. +library; + +export 'src/persistence/persistence.dart'; +export 'src/settings_controller.dart'; diff --git a/packages/settings_controller/lib/src/persistence/in_memory_settings_persistence.dart b/packages/settings_controller/lib/src/persistence/in_memory_settings_persistence.dart new file mode 100644 index 0000000..a2af800 --- /dev/null +++ b/packages/settings_controller/lib/src/persistence/in_memory_settings_persistence.dart @@ -0,0 +1,65 @@ +import 'dart:ui'; + +import 'package:settings_controller/settings_controller.dart'; + +/// An in-memory implementation of [SettingsPersistence]. +/// Useful for testing. +class InMemorySettingsPersistence implements SettingsPersistence { + /// The saved locale. + Locale? locale; + + /// The saved dark mode setting. + bool? darkModeOn; + + /// The saved initial setup completion setting. + bool? initialSetupCompleted; + + /// The saved IP address setting. + String? ipAddress; + + /// The saved port setting. + int? port; + + @override + Future getLocale() async => locale; + + @override + Future getDarkModeOn() async => darkModeOn; + + @override + Future getInitialSetupCompleted() async => initialSetupCompleted; + + @override + Future getIpAddress() async => ipAddress; + + @override + Future getPort() async => port; + + @override + Future saveLocale({required Locale locale}) async => + this.locale = locale; + + @override + Future saveDarkModeOn({required bool active}) async => + darkModeOn = active; + + @override + Future saveInitialSetupCompleted({required bool completed}) async => + initialSetupCompleted = completed; + + @override + Future saveIpAddress({required String? ipAddress}) async => + this.ipAddress = ipAddress; + + @override + Future savePort({required int? port}) async => this.port = port; + + @override + Future clear() async { + locale = null; + darkModeOn = null; + initialSetupCompleted = null; + ipAddress = null; + port = null; + } +} diff --git a/packages/settings_controller/lib/src/persistence/local_storage_settings_persistence.dart b/packages/settings_controller/lib/src/persistence/local_storage_settings_persistence.dart new file mode 100644 index 0000000..83f559e --- /dev/null +++ b/packages/settings_controller/lib/src/persistence/local_storage_settings_persistence.dart @@ -0,0 +1,109 @@ +import 'dart:ui'; + +import 'package:intl/locale.dart' as intl; +import 'package:settings_controller/settings_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// An implementation of [SettingsPersistence] that uses +/// `package:shared_preferences`. +class LocalStorageSettingsPersistence implements SettingsPersistence { + final Future _instanceFuture = + SharedPreferences.getInstance(); + + /// Parses [rawLocale] to produce [Locale]. + static Locale? _parseLocale(String rawLocale) { + final intlLocale = intl.Locale.tryParse(rawLocale); + + if (intlLocale != null) { + return Locale.fromSubtags( + languageCode: intlLocale.languageCode, + countryCode: intlLocale.countryCode, + scriptCode: intlLocale.scriptCode, + ); + } + + return null; + } + + @override + Future getLocale() async { + final prefs = await _instanceFuture; + final rawLocale = prefs.getString('locale'); + + if (rawLocale == null) { + return null; + } + + return _parseLocale(rawLocale); + } + + @override + Future getDarkModeOn() async { + final prefs = await _instanceFuture; + return prefs.getBool('darkModeOn'); + } + + @override + Future getInitialSetupCompleted() async { + final prefs = await _instanceFuture; + return prefs.getBool('initialSetupCompleted'); + } + + @override + Future getIpAddress() async { + final prefs = await _instanceFuture; + return prefs.getString('ipAddress'); + } + + @override + Future getPort() async { + final prefs = await _instanceFuture; + return prefs.getInt('port'); + } + + @override + Future saveLocale({required Locale locale}) async { + final prefs = await _instanceFuture; + await prefs.setString('locale', locale.toLanguageTag()); + } + + @override + Future saveDarkModeOn({required bool active}) async { + final prefs = await _instanceFuture; + await prefs.setBool('darkModeOn', active); + } + + @override + Future saveInitialSetupCompleted({required bool completed}) async { + final prefs = await _instanceFuture; + await prefs.setBool('initialSetupCompleted', completed); + } + + @override + Future saveIpAddress({required String? ipAddress}) async { + final prefs = await _instanceFuture; + + if (ipAddress == null) { + await prefs.remove('ipAddress'); + } else { + await prefs.setString('ipAddress', ipAddress); + } + } + + @override + Future savePort({required int? port}) async { + final prefs = await _instanceFuture; + + if (port == null) { + await prefs.remove('port'); + } else { + await prefs.setInt('port', port); + } + } + + @override + Future clear() async { + final prefs = await _instanceFuture; + await prefs.clear(); + } +} diff --git a/packages/settings_controller/lib/src/persistence/persistence.dart b/packages/settings_controller/lib/src/persistence/persistence.dart new file mode 100644 index 0000000..2bbe8dd --- /dev/null +++ b/packages/settings_controller/lib/src/persistence/persistence.dart @@ -0,0 +1,3 @@ +export 'in_memory_settings_persistence.dart'; +export 'local_storage_settings_persistence.dart'; +export 'settings_persistence.dart'; diff --git a/packages/settings_controller/lib/src/persistence/settings_persistence.dart b/packages/settings_controller/lib/src/persistence/settings_persistence.dart new file mode 100644 index 0000000..41f5129 --- /dev/null +++ b/packages/settings_controller/lib/src/persistence/settings_persistence.dart @@ -0,0 +1,40 @@ +import 'dart:ui'; + +/// An interface of persistence stores for settings. +/// +/// Implementations can range from simple in-memory storage through +/// local preferences to cloud-based solutions. +abstract class SettingsPersistence { + /// Returns the latest saved locale. + Future getLocale(); + + /// Returns the latest saved dark mode setting. + Future getDarkModeOn(); + + /// Returns the latest saved initial setup completion setting. + Future getInitialSetupCompleted(); + + /// Returns the latest saved IP address setting. + Future getIpAddress(); + + /// Returns the latest saved port setting. + Future getPort(); + + /// Saves locale. + Future saveLocale({required Locale locale}); + + /// Saves dark mode setting. + Future saveDarkModeOn({required bool active}); + + /// Saves initial setup completion setting. + Future saveInitialSetupCompleted({required bool completed}); + + /// Saves IP address setting. + Future saveIpAddress({required String? ipAddress}); + + /// Saves port setting. + Future savePort({required int? port}); + + /// Clears the store. + Future clear(); +} diff --git a/packages/settings_controller/lib/src/settings_controller.dart b/packages/settings_controller/lib/src/settings_controller.dart new file mode 100644 index 0000000..4390f5a --- /dev/null +++ b/packages/settings_controller/lib/src/settings_controller.dart @@ -0,0 +1,98 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:settings_controller/settings_controller.dart'; + +/// {@template settings_controller} +/// An class that holds settings like [locale] or [darkModeOn], +/// and saves them to an injected persistence store. +/// {@endtemplate} +class SettingsController { + /// {@macro settings_controller} + SettingsController({ + required SettingsPersistence persistence, + }) : _persistence = persistence; + + final SettingsPersistence _persistence; + + final ValueNotifier _locale = ValueNotifier(null); + + final ValueNotifier _darkModeOn = ValueNotifier(null); + + final ValueNotifier _initialSetupCompleted = ValueNotifier(null); + + final ValueNotifier _ipAddress = ValueNotifier(null); + + final ValueNotifier _port = ValueNotifier(null); + + /// Returns latest saved [Locale] value. + ValueNotifier get locale => _locale; + + /// Returns latest saved dark mode setting value. + ValueNotifier get darkModeOn => _darkModeOn; + + /// Returns latest saved initial setup completion setting value. + ValueNotifier get initialSetupCompleted => _initialSetupCompleted; + + /// Returns latest saved IP address setting value. + ValueNotifier get ipAddress => _ipAddress; + + /// Returns latest saved port setting value. + ValueNotifier get port => _port; + + /// Asynchronously loads values from the injected persistence store. + Future loadStateFromPersistence() async { + await Future.wait([ + _persistence.getLocale().then((value) => _locale.value = value), + _persistence.getDarkModeOn().then((value) => _darkModeOn.value = value), + _persistence + .getInitialSetupCompleted() + .then((value) => _initialSetupCompleted.value = value), + _persistence.getIpAddress().then((value) => _ipAddress.value = value), + _persistence.getPort().then((value) => _port.value = value), + ]); + } + + /// Sets new locale value and saves it in the persistence store. + void setLocale({required Locale locale}) { + _locale.value = locale; + _persistence.saveLocale(locale: locale); + } + + /// Sets new dark mode setting value and saves it in the persistence store. + void setDarkModeOn({required bool active}) { + _darkModeOn.value = active; + _persistence.saveDarkModeOn(active: active); + } + + /// Sets new initial setup completion setting value and saves it in the + /// persistence store. + void setInitialSetupCompleted({required bool completed}) { + _initialSetupCompleted.value = completed; + _persistence.saveInitialSetupCompleted(completed: completed); + } + + /// Sets new IP address setting value and saves it in the persistence store. + void setIpAddress({required String? ipAddress}) { + _ipAddress.value = ipAddress; + _persistence.saveIpAddress(ipAddress: ipAddress); + } + + /// Sets new port setting value and saves it in the persistence store. + void setPort({required int? port}) { + _port.value = port; + _persistence.savePort(port: port); + } + + /// Resets all settings and clears persistence store. + Future clear() async { + _locale.value = null; + _darkModeOn.value = null; + _initialSetupCompleted.value = null; + _ipAddress.value = null; + _port.value = null; + + await _persistence.clear(); + } +} diff --git a/packages/settings_controller/pubspec.yaml b/packages/settings_controller/pubspec.yaml new file mode 100644 index 0000000..3fe4e8d --- /dev/null +++ b/packages/settings_controller/pubspec.yaml @@ -0,0 +1,20 @@ +name: settings_controller +description: Controller to manage settings. +version: 1.0.0 +publish_to: none + +environment: + sdk: '>=3.0.1 <4.0.0' + flutter: ">=3.10.0" + +dependencies: + flutter: + sdk: flutter + intl: ^0.18.1 + shared_preferences: ^2.2.1 + +dev_dependencies: + flutter_test: + sdk: flutter + mocktail: 1.0.0 + very_good_analysis: 5.1.0 diff --git a/packages/settings_controller/test/persistence/in_memory_settings_persistence_test.dart b/packages/settings_controller/test/persistence/in_memory_settings_persistence_test.dart new file mode 100644 index 0000000..0d0f730 --- /dev/null +++ b/packages/settings_controller/test/persistence/in_memory_settings_persistence_test.dart @@ -0,0 +1,212 @@ +// ignore_for_file: prefer_const_constructors + +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:settings_controller/settings_controller.dart'; + +void main() { + group('InMemorySettingsPersistence', () { + group('getLocale', () { + test('returns null if locale was not saved previously', () async { + final persistence = InMemorySettingsPersistence(); + + final value = await persistence.getLocale(); + + expect(value, isNull); + }); + + test('returns value if locale was saved previously', () async { + final persistence = InMemorySettingsPersistence() + ..locale = Locale('uk', 'UA'); + + final value = await persistence.getLocale(); + + expect( + value, + equals(Locale('uk', 'UA')), + ); + }); + }); + + group('getDarkModeOn', () { + test('returns null if setting was not saved previously', () async { + final persistence = InMemorySettingsPersistence(); + final value = await persistence.getDarkModeOn(); + expect(value, isNull); + }); + + test('returns value if setting was saved previously', () async { + final persistence = InMemorySettingsPersistence()..darkModeOn = false; + final value = await persistence.getDarkModeOn(); + expect(value, isFalse); + }); + }); + + group('getInitialSetupCompleted', () { + test('returns null if setting was not saved previously', () async { + final persistence = InMemorySettingsPersistence(); + final value = await persistence.getInitialSetupCompleted(); + expect(value, isNull); + }); + + test('returns value if setting was saved previously', () async { + final persistence = InMemorySettingsPersistence() + ..initialSetupCompleted = false; + + final value = await persistence.getInitialSetupCompleted(); + expect(value, isFalse); + }); + }); + + group('getIpAddress', () { + test('returns null if setting was not saved previously', () async { + final persistence = InMemorySettingsPersistence(); + final value = await persistence.getIpAddress(); + expect(value, isNull); + }); + + test('returns value if setting was saved previously', () async { + final persistence = InMemorySettingsPersistence() + ..ipAddress = '192.168.0.1'; + + final value = await persistence.getIpAddress(); + + expect( + value, + equals('192.168.0.1'), + ); + }); + }); + + group('getIpAddress', () { + test('returns null if setting was not saved previously', () async { + final persistence = InMemorySettingsPersistence(); + final value = await persistence.getIpAddress(); + expect(value, isNull); + }); + + test('returns value if setting was saved previously', () async { + final persistence = InMemorySettingsPersistence() + ..ipAddress = '192.168.0.1'; + + final value = await persistence.getIpAddress(); + + expect( + value, + equals('192.168.0.1'), + ); + }); + }); + + group('getPort', () { + test('returns null if setting was not saved previously', () async { + final persistence = InMemorySettingsPersistence(); + final value = await persistence.getPort(); + expect(value, isNull); + }); + + test('returns value if setting was saved previously', () async { + final persistence = InMemorySettingsPersistence()..port = 8888; + + final value = await persistence.getPort(); + + expect( + value, + equals(8888), + ); + }); + }); + + test('saveLocale', () async { + final persistence = InMemorySettingsPersistence(); + + await persistence.saveLocale(locale: Locale('uk', 'UA')); + + expect( + await persistence.getLocale(), + equals(Locale('uk', 'UA')), + ); + }); + + test('saveDarkModeOn', () async { + final persistence = InMemorySettingsPersistence(); + + await persistence.saveDarkModeOn(active: true); + + expect( + await persistence.getDarkModeOn(), + isTrue, + ); + }); + + test('saveInitialSetupCompleted', () async { + final persistence = InMemorySettingsPersistence(); + + await persistence.saveInitialSetupCompleted(completed: true); + + expect( + await persistence.getInitialSetupCompleted(), + isTrue, + ); + }); + + test('saveIpAddress', () async { + final persistence = InMemorySettingsPersistence(); + + await persistence.saveIpAddress(ipAddress: '192.168.0.2'); + + expect( + await persistence.getIpAddress(), + equals('192.168.0.2'), + ); + }); + + test('savePort', () async { + final persistence = InMemorySettingsPersistence(); + + await persistence.savePort(port: 3333); + + expect( + await persistence.getPort(), + equals(3333), + ); + }); + + test('clear', () async { + final persistence = InMemorySettingsPersistence() + ..locale = Locale('uk', 'UA') + ..darkModeOn = true + ..initialSetupCompleted = true + ..ipAddress = '192.168.0.1' + ..port = 8888; + + await persistence.clear(); + + expect( + await persistence.getLocale(), + isNull, + ); + + expect( + await persistence.getDarkModeOn(), + isNull, + ); + + expect( + await persistence.getInitialSetupCompleted(), + isNull, + ); + + expect( + await persistence.getIpAddress(), + isNull, + ); + + expect( + await persistence.getPort(), + isNull, + ); + }); + }); +} diff --git a/packages/settings_controller/test/persistence/local_storage_settings_persistence_test.dart b/packages/settings_controller/test/persistence/local_storage_settings_persistence_test.dart new file mode 100644 index 0000000..0914be6 --- /dev/null +++ b/packages/settings_controller/test/persistence/local_storage_settings_persistence_test.dart @@ -0,0 +1,288 @@ +// ignore_for_file: prefer_const_constructors + +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:settings_controller/settings_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + group('LocalStorageSettingsPersistence', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('getLocale', () { + test('returns null if locale was not saved previously', () async { + final persistence = LocalStorageSettingsPersistence(); + + expect( + await persistence.getLocale(), + isNull, + ); + }); + + test('returns value if locale was saved previously', () async { + SharedPreferences.setMockInitialValues({ + 'locale': 'uk_UA', + }); + + final persistence = LocalStorageSettingsPersistence(); + + expect( + await persistence.getLocale(), + equals(Locale('uk', 'UA')), + ); + }); + + test('returns null if locale is not valid', () async { + SharedPreferences.setMockInitialValues({ + 'locale': '123', + }); + + final persistence = LocalStorageSettingsPersistence(); + + expect( + await persistence.getLocale(), + isNull, + ); + }); + }); + + group('getDarkModeOn', () { + test('returns null if setting was not saved previously', () async { + final persistence = LocalStorageSettingsPersistence(); + + expect( + await persistence.getDarkModeOn(), + isNull, + ); + }); + + test('returns value if setting was saved previously', () async { + SharedPreferences.setMockInitialValues({ + 'darkModeOn': false, + }); + + final persistence = LocalStorageSettingsPersistence(); + + expect( + await persistence.getDarkModeOn(), + isFalse, + ); + }); + }); + + group('getInitialSetupCompleted', () { + test('returns null if setting was not saved previously', () async { + final persistence = LocalStorageSettingsPersistence(); + + expect( + await persistence.getInitialSetupCompleted(), + isNull, + ); + }); + + test('returns value if setting was saved previously', () async { + SharedPreferences.setMockInitialValues({ + 'initialSetupCompleted': false, + }); + + final persistence = LocalStorageSettingsPersistence(); + + expect( + await persistence.getInitialSetupCompleted(), + isFalse, + ); + }); + }); + + group('getIpAddress', () { + test('returns null if setting was not saved previously', () async { + final persistence = LocalStorageSettingsPersistence(); + + expect( + await persistence.getIpAddress(), + isNull, + ); + }); + + test('returns value if setting was saved previously', () async { + SharedPreferences.setMockInitialValues({ + 'ipAddress': '192.168.0.1', + }); + + final persistence = LocalStorageSettingsPersistence(); + + expect( + await persistence.getIpAddress(), + equals('192.168.0.1'), + ); + }); + }); + + group('getPort', () { + test('returns null if setting was not saved previously', () async { + final persistence = LocalStorageSettingsPersistence(); + + expect( + await persistence.getPort(), + isNull, + ); + }); + + test('returns value if setting was saved previously', () async { + SharedPreferences.setMockInitialValues({ + 'port': 8888, + }); + + final persistence = LocalStorageSettingsPersistence(); + + expect( + await persistence.getPort(), + equals(8888), + ); + }); + }); + + test('saveLocale', () async { + final persistence = LocalStorageSettingsPersistence(); + + await persistence.saveLocale( + locale: Locale('uk', 'UA'), + ); + + expect( + await persistence.getLocale(), + equals(Locale('uk', 'UA')), + ); + }); + + test('saveDarkModeOn', () async { + final persistence = LocalStorageSettingsPersistence(); + + await persistence.saveDarkModeOn(active: true); + + expect( + await persistence.getDarkModeOn(), + isTrue, + ); + }); + + test('saveInitialSetupCompleted', () async { + final persistence = LocalStorageSettingsPersistence(); + + await persistence.saveInitialSetupCompleted(completed: true); + + expect( + await persistence.getInitialSetupCompleted(), + isTrue, + ); + }); + + group('saveIpAddress', () { + test('saves IP address if value is not null', () async { + final persistence = LocalStorageSettingsPersistence(); + + await persistence.saveIpAddress(ipAddress: '192.168.0.2'); + + expect( + await persistence.getIpAddress(), + equals('192.168.0.2'), + ); + }); + + test('removes IP address if value is null', () async { + SharedPreferences.setMockInitialValues({ + 'ipAddress': '192.168.0.1', + }); + + final persistence = LocalStorageSettingsPersistence(); + + expect( + await persistence.getIpAddress(), + equals('192.168.0.1'), + ); + + await persistence.saveIpAddress(ipAddress: null); + + expect( + await persistence.getIpAddress(), + isNull, + ); + }); + }); + + group('savePort', () { + test('saves port if value is not null', () async { + final persistence = LocalStorageSettingsPersistence(); + + await persistence.savePort(port: 3333); + + expect( + await persistence.getPort(), + equals(3333), + ); + }); + + test('removes port if value is null', () async { + SharedPreferences.setMockInitialValues({ + 'port': 8888, + }); + + final persistence = LocalStorageSettingsPersistence(); + + expect( + await persistence.getPort(), + equals(8888), + ); + + await persistence.savePort(port: null); + + expect( + await persistence.getPort(), + isNull, + ); + }); + }); + + test('clear', () async { + SharedPreferences.setMockInitialValues({ + 'locale': 'uk_UA', + 'darkModeOn': true, + 'initialSetupCompleted': true, + 'ipAddress': '192.168.0.1', + 'port': 8888, + }); + + final persistence = LocalStorageSettingsPersistence(); + + await persistence.clear(); + + expect( + await persistence.getLocale(), + isNull, + ); + + expect( + await persistence.getDarkModeOn(), + isNull, + ); + + expect( + await persistence.getInitialSetupCompleted(), + isNull, + ); + + expect( + await persistence.getIpAddress(), + isNull, + ); + + expect( + await persistence.getPort(), + isNull, + ); + }); + }); +} diff --git a/packages/settings_controller/test/settings_controller_test.dart b/packages/settings_controller/test/settings_controller_test.dart new file mode 100644 index 0000000..2a4ccdd --- /dev/null +++ b/packages/settings_controller/test/settings_controller_test.dart @@ -0,0 +1,189 @@ +// ignore_for_file: prefer_const_constructors + +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class _MockSettingsPersistence extends Mock implements SettingsPersistence {} + +void main() { + group('SettingsController', () { + late SettingsPersistence persistence; + late SettingsController controller; + + setUpAll(() { + registerFallbackValue(Locale('en')); + }); + + setUp(() { + persistence = _MockSettingsPersistence(); + + when(persistence.getLocale).thenAnswer((_) async => Locale('uk', 'UA')); + when(persistence.getDarkModeOn).thenAnswer((_) async => true); + when(persistence.getInitialSetupCompleted).thenAnswer((_) async => true); + when(persistence.getIpAddress).thenAnswer((_) async => '192.168.0.1'); + when(persistence.getPort).thenAnswer((_) async => 8888); + + when( + () => persistence.saveLocale( + locale: any(named: 'locale'), + ), + ).thenAnswer( + (_) async {}, + ); + when( + () => persistence.saveDarkModeOn( + active: any(named: 'active'), + ), + ).thenAnswer( + (_) async {}, + ); + when( + () => persistence.saveInitialSetupCompleted( + completed: any(named: 'completed'), + ), + ).thenAnswer( + (_) async {}, + ); + when( + () => persistence.saveIpAddress( + ipAddress: any(named: 'ipAddress'), + ), + ).thenAnswer( + (_) async {}, + ); + when( + () => persistence.savePort( + port: any(named: 'port'), + ), + ).thenAnswer( + (_) async {}, + ); + + when(persistence.clear).thenAnswer((_) async {}); + + controller = SettingsController(persistence: persistence); + }); + + test('loadStateFromPersistence', () async { + await controller.loadStateFromPersistence(); + + expect( + controller.locale.value, + equals(Locale('uk', 'UA')), + ); + verify(persistence.getLocale).called(1); + + expect(controller.darkModeOn.value, isTrue); + verify(persistence.getDarkModeOn).called(1); + + expect(controller.initialSetupCompleted.value, isTrue); + verify(persistence.getInitialSetupCompleted).called(1); + + expect( + controller.ipAddress.value, + equals('192.168.0.1'), + ); + verify(persistence.getIpAddress).called(1); + + expect( + controller.port.value, + equals(8888), + ); + verify(persistence.getPort).called(1); + }); + + test('can set locale', () async { + controller.setLocale(locale: Locale('en', 'GB')); + + expect( + controller.locale.value, + equals(Locale('en', 'GB')), + ); + + verify( + () => persistence.saveLocale(locale: Locale('en', 'GB')), + ).called(1); + }); + + test('can set dark mode setting', () async { + controller.setDarkModeOn(active: false); + + expect(controller.darkModeOn.value, isFalse); + + verify( + () => persistence.saveDarkModeOn(active: false), + ).called(1); + }); + + test('can set initial setup completed setting', () async { + controller.setInitialSetupCompleted(completed: false); + + expect(controller.initialSetupCompleted.value, isFalse); + + verify( + () => persistence.saveInitialSetupCompleted(completed: false), + ).called(1); + }); + + test('can set IP address setting', () async { + controller.setIpAddress(ipAddress: '192.168.0.2'); + + expect( + controller.ipAddress.value, + equals('192.168.0.2'), + ); + + verify( + () => persistence.saveIpAddress(ipAddress: '192.168.0.2'), + ).called(1); + }); + + test('can set port setting', () async { + controller.setPort(port: 3333); + + expect( + controller.port.value, + equals(3333), + ); + + verify( + () => persistence.savePort(port: 3333), + ).called(1); + }); + + group('clear', () { + test('calls clear() on persistence', () async { + await controller.clear(); + verify(persistence.clear).called(1); + }); + + test('resets all settings', () async { + await controller.clear(); + + expect( + controller.locale.value, + isNull, + ); + expect( + controller.darkModeOn.value, + isNull, + ); + expect( + controller.initialSetupCompleted.value, + isNull, + ); + expect( + controller.ipAddress.value, + isNull, + ); + expect( + controller.port.value, + isNull, + ); + }); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..c983d35 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,905 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + url: "https://pub.dev" + source: hosted + version: "61.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + url: "https://pub.dev" + source: hosted + version: "5.13.0" + archive: + dependency: transitive + description: + name: archive + sha256: "20071638cbe4e5964a427cfa0e86dce55d060bc7d82d56f3554095d7239a8765" + url: "https://pub.dev" + source: hosted + version: "3.4.2" + args: + dependency: transitive + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + bloc: + dependency: "direct main" + description: + name: bloc + sha256: "3820f15f502372d979121de1f6b97bfcf1630ebff8fe1d52fb2b0bfa49be5b49" + url: "https://pub.dev" + source: hosted + version: "8.1.2" + bloc_concurrency: + dependency: "direct main" + description: + name: bloc_concurrency + sha256: "44535c9f429cd7e91d548cf89fde1c23a8b4b3637decdb1865bb583091a00d4e" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: af0de1a1e16a7536e95dcd7491e0a6d6078e11d2d691988e862280b74f5c7968 + url: "https://pub.dev" + source: hosted + version: "9.1.4" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + url: "https://pub.dev" + source: hosted + version: "1.17.2" + connection_repository: + dependency: "direct main" + description: + path: "packages/connection_repository" + relative: true + source: path + version: "1.0.0" + control_repository: + dependency: "direct main" + description: + path: "packages/control_repository" + relative: true + source: path + version: "1.0.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" + url: "https://pub.dev" + source: hosted + version: "1.6.3" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + file: + dependency: transitive + description: + name: file + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: e74efb89ee6945bcbce74a5b3a5a3376b088e5f21f55c263fc38cbdc6237faae + url: "https://pub.dev" + source: hosted + version: "8.1.3" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + formz: + dependency: "direct main" + description: + name: formz + sha256: df8301299601139de7e653e68a07c332fd2db7cec65745eca1a1ea73fb711e06 + url: "https://pub.dev" + source: hosted + version: "0.6.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + gap: + dependency: transitive + description: + name: gap + sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d + url: "https://pub.dev" + source: hosted + version: "3.0.1" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + graphs: + dependency: transitive + description: + name: graphs + sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + gyver_lamp_client: + dependency: "direct main" + description: + path: "packages/gyver_lamp_client" + relative: true + source: path + version: "1.0.0" + gyver_lamp_effects: + dependency: "direct main" + description: + path: "packages/gyver_lamp_effects" + relative: true + source: path + version: "1.0.0" + gyver_lamp_icons: + dependency: "direct main" + description: + path: "packages/gyver_lamp_icons" + relative: true + source: path + version: "1.0.0" + gyver_lamp_ui: + dependency: "direct main" + description: + path: "packages/gyver_lamp_ui" + relative: true + source: path + version: "1.0.0" + http: + dependency: transitive + description: + name: http + sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + image: + dependency: transitive + description: + name: image + sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" + url: "https://pub.dev" + source: hosted + version: "4.1.3" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" + source: hosted + version: "4.8.1" + logging: + dependency: "direct main" + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + url: "https://pub.dev" + source: hosted + version: "0.12.16" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + meta: + dependency: transitive + description: + name: meta + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + mime: + dependency: transitive + description: + name: mime + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + url: "https://pub.dev" + source: hosted + version: "1.0.4" + mockingjay: + dependency: "direct dev" + description: + name: mockingjay + sha256: "9ef2c471ab3db0f19a8e9600e255297ac13d1130a9b8f1926294ee6d0cb7d68f" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "9503969a7c2c78c7292022c70c0289ed6241df7a9ba720010c0b215af29a5a58" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + url: "https://pub.dev" + source: hosted + version: "1.8.3" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + url: "https://pub.dev" + source: hosted + version: "5.4.0" + platform: + dependency: transitive + description: + name: platform + sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + url: "https://pub.dev" + source: hosted + version: "3.7.3" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + provider: + dependency: "direct main" + description: + name: provider + sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f + url: "https://pub.dev" + source: hosted + version: "6.0.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + rive: + dependency: "direct main" + description: + name: rive + sha256: f2117a96a189758bc79bf7933865625c7a44a420ae537d2a8f6c492900136a71 + url: "https://pub.dev" + source: hosted + version: "0.11.17" + rive_common: + dependency: transitive + description: + name: rive_common + sha256: e41f12917cb58e0c9376836490ebaa431e12744da0c67e19dad8d4bee9fedd46 + url: "https://pub.dev" + source: hosted + version: "0.2.7" + settings_controller: + dependency: "direct main" + description: + path: "packages/settings_controller" + relative: true + source: path + version: "1.0.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: b7f41bad7e521d205998772545de63ff4e6c97714775902c199353f8bf1511ac + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" + url: "https://pub.dev" + source: hosted + version: "2.3.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: c2eb5bf57a2fe9ad6988121609e47d3e07bb3bdca5b6f8444e4cf302428a128a + url: "https://pub.dev" + source: hosted + version: "2.3.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a + url: "https://pub.dev" + source: hosted + version: "2.3.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: f763a101313bd3be87edffe0560037500967de9c394a714cd598d945517f694f + url: "https://pub.dev" + source: hosted + version: "2.3.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + url: "https://pub.dev" + source: hosted + version: "1.11.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + tactile_feedback: + dependency: transitive + description: + path: "." + ref: "014245c2d2c2a0a888c37fc63a016141f10956c8" + resolved-ref: "014245c2d2c2a0a888c37fc63a016141f10956c8" + url: "https://github.com/ksokolovskyi/tactile_feedback" + source: git + version: "1.0.2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: "13b41f318e2a5751c3169137103b60c584297353d4b1761b66029bae6411fe46" + url: "https://pub.dev" + source: hosted + version: "1.24.3" + test_api: + dependency: transitive + description: + name: test_api + sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + test_core: + dependency: transitive + description: + name: test_core + sha256: "99806e9e6d95c7b059b7a0fc08f07fc53fabe54a829497f0d9676299f1e8637e" + url: "https://pub.dev" + source: hosted + version: "0.5.3" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" + url: "https://pub.dev" + source: hosted + version: "6.1.14" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330 + url: "https://pub.dev" + source: hosted + version: "6.1.0" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7c65021d5dee51813d652357bc65b8dd4a6177082a9966bc8ba6ee477baa795f" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: b651aad005e0cb06a01dbd84b428a301916dc75f0e7ea6165f80057fee2d8e8e + url: "https://pub.dev" + source: hosted + version: "3.0.6" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88 + url: "https://pub.dev" + source: hosted + version: "3.0.7" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "95465b39f83bfe95fcb9d174829d6476216f2d548b79c38ab2506e0458787618" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "2942294a500b4fa0b918685aff406773ba0a4cd34b7f42198742a94083020ce5" + url: "https://pub.dev" + source: hosted + version: "2.0.20" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "95fef3129dc7cfaba2bc3d5ba2e16063bb561fc6d78e63eee16162bc70029069" + url: "https://pub.dev" + source: hosted + version: "3.0.8" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + sha256: "9ae7f3a3bd5764fb021b335ca28a34f040cd0ab6eec00a1b213b445dae58a4b8" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 + url: "https://pub.dev" + source: hosted + version: "11.10.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + url: "https://pub.dev" + source: hosted + version: "0.1.4-beta" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3" + url: "https://pub.dev" + source: hosted + version: "5.0.9" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + xml: + dependency: transitive + description: + name: xml + sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..df1e7b1 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,56 @@ +name: gyver_lamp +description: Gyver Lamp Application. +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.13.0' + +dependencies: + bloc: 8.1.2 + bloc_concurrency: 0.2.2 + connection_repository: + path: packages/connection_repository + control_repository: + path: packages/control_repository + equatable: 2.0.5 + flutter: + sdk: flutter + flutter_bloc: 8.1.3 + flutter_localizations: + sdk: flutter + formz: 0.6.1 + gyver_lamp_client: + path: packages/gyver_lamp_client + gyver_lamp_effects: + path: packages/gyver_lamp_effects + gyver_lamp_icons: + path: packages/gyver_lamp_icons + gyver_lamp_ui: + path: packages/gyver_lamp_ui + intl: 0.18.1 + logging: 1.2.0 + provider: 6.0.5 + rive: 0.11.17 + settings_controller: + path: packages/settings_controller + url_launcher: 6.1.14 + +dev_dependencies: + bloc_test: 9.1.4 + flutter_launcher_icons: 0.13.1 + flutter_test: + sdk: flutter + mockingjay: 0.4.0 + mocktail: 1.0.0 + test: 1.24.3 + very_good_analysis: 5.1.0 + +flutter: + uses-material-design: true + generate: true + + assets: + - assets/switch.riv \ No newline at end of file diff --git a/test/app/models/app_data_test.dart b/test/app/models/app_data_test.dart new file mode 100644 index 0000000..e699c0f --- /dev/null +++ b/test/app/models/app_data_test.dart @@ -0,0 +1,33 @@ +import 'package:connection_repository/connection_repository.dart'; +import 'package:control_repository/control_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/app/app.dart'; +import 'package:gyver_lamp/connection/models/connection_data.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class _MockConnectionRepository extends Mock implements ConnectionRepository {} + +class _MockControlRepository extends Mock implements ControlRepository {} + +class _MockSettingsController extends Mock implements SettingsController {} + +void main() { + group('AppData', () { + test('can be instantiated', () { + expect( + AppData( + connectionRepository: _MockConnectionRepository(), + controlRepository: _MockControlRepository(), + settingsController: _MockSettingsController(), + initialConnectionData: const ConnectionData( + address: '192.168.1.5', + port: 8888, + ), + initialSetupCompleted: true, + ), + isNotNull, + ); + }); + }); +} diff --git a/test/app/view/app_loader_test.dart b/test/app/view/app_loader_test.dart new file mode 100644 index 0000000..0389c31 --- /dev/null +++ b/test/app/view/app_loader_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:connection_repository/connection_repository.dart'; +import 'package:control_repository/control_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/app/app.dart'; +import 'package:gyver_lamp/splash/splash.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class _MockConnectionRepository extends Mock implements ConnectionRepository {} + +class _MockControlRepository extends Mock implements ControlRepository {} + +class _MockSettingsController extends Mock implements SettingsController {} + +void main() { + late ConnectionRepository connectionRepository; + late ControlRepository controlRepository; + late SettingsController settingsController; + + late ValueNotifier locale; + late ValueNotifier darkModeOn; + + late AppData appData; + + setUp(() { + connectionRepository = _MockConnectionRepository(); + controlRepository = _MockControlRepository(); + settingsController = _MockSettingsController(); + + locale = ValueNotifier(null); + when(() => settingsController.locale).thenReturn(locale); + + darkModeOn = ValueNotifier(null); + when(() => settingsController.darkModeOn).thenReturn(darkModeOn); + + appData = AppData( + connectionRepository: connectionRepository, + controlRepository: controlRepository, + settingsController: settingsController, + initialConnectionData: null, + initialSetupCompleted: false, + ); + }); + + tearDown(() { + locale.dispose(); + darkModeOn.dispose(); + }); + + group( + 'AppLoader', + () { + testWidgets('shows SplashPage when loading', (tester) async { + final completer = Completer(); + + await tester.pumpWidget( + AppLoader( + dataLoader: () => completer.future, + ), + ); + + expect(find.byType(SplashPage), findsOneWidget); + }); + + testWidgets( + 'first shows SplashPage when dataLoader completes and then shows App ' + 'after delay', + (tester) async { + final completer = Completer(); + + await tester.pumpWidget( + AppLoader( + dataLoader: () => completer.future, + ), + ); + + expect(find.byType(SplashPage), findsOneWidget); + + completer.complete(appData); + await tester.pump(); + + expect(find.byType(SplashPage), findsOneWidget); + + await tester.pump( + SplashPage.kFadeDuration + SplashPage.kAnimationDuration, + ); + + expect(find.byType(App), findsOneWidget); + }, + ); + }, + ); +} diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart new file mode 100644 index 0000000..cfe6ba4 --- /dev/null +++ b/test/app/view/app_test.dart @@ -0,0 +1,336 @@ +import 'dart:async'; + +import 'package:connection_repository/connection_repository.dart'; +import 'package:control_repository/control_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/app/app.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/initial_setup/initial_setup.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart' hide ConnectionStatus; +import 'package:mockingjay/mockingjay.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class _MockConnectionRepository extends Mock implements ConnectionRepository {} + +class _MockControlRepository extends Mock implements ControlRepository {} + +class _MockSettingsController extends Mock implements SettingsController {} + +void main() { + const address = '192.168.1.5'; + const port = 8888; + + late ConnectionRepository connectionRepository; + late ControlRepository controlRepository; + late SettingsController settingsController; + + late ValueNotifier locale; + late ValueNotifier darkModeOn; + + setUp(() { + connectionRepository = _MockConnectionRepository(); + controlRepository = _MockControlRepository(); + settingsController = _MockSettingsController(); + + locale = ValueNotifier(null); + when(() => settingsController.locale).thenReturn(locale); + + darkModeOn = ValueNotifier(null); + when(() => settingsController.darkModeOn).thenReturn(darkModeOn); + }); + + tearDown(() { + locale.dispose(); + darkModeOn.dispose(); + }); + + group('App', () { + group('renders correctly', () { + testWidgets('when initialSetupCompleted is false', (tester) async { + await tester.pumpWidget( + App( + data: AppData( + connectionRepository: connectionRepository, + controlRepository: controlRepository, + settingsController: settingsController, + initialConnectionData: null, + initialSetupCompleted: false, + ), + ), + ); + + expect(find.byType(App), findsOneWidget); + expect(find.byType(InitialSetupView), findsOneWidget); + }); + + testWidgets('when initialSetupCompleted is true', (tester) async { + await tester.pumpWidget( + App( + data: AppData( + connectionRepository: connectionRepository, + controlRepository: controlRepository, + settingsController: settingsController, + initialConnectionData: null, + initialSetupCompleted: true, + ), + ), + ); + + expect(find.byType(App), findsOneWidget); + expect(find.byType(ControlPage), findsOneWidget); + }); + }); + + testWidgets( + 'initiates connection when initialConnectionData is not null', + (tester) async { + when( + () => connectionRepository.connect( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenAnswer((_) async {}); + + final statusesController = StreamController(); + addTearDown(statusesController.close); + when(() => connectionRepository.statuses).thenAnswer( + (_) => statusesController.stream, + ); + + await tester.pumpWidget( + App( + data: AppData( + connectionRepository: connectionRepository, + controlRepository: controlRepository, + settingsController: settingsController, + initialConnectionData: const ConnectionData( + address: address, + port: port, + ), + initialSetupCompleted: true, + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 500)); + + verify( + () => connectionRepository.connect(address: address, port: port), + ).called(1); + verify(() => connectionRepository.statuses).called(1); + }, + ); + + group('themeMode', () { + testWidgets( + 'is ThemeMode.system when darkModeOn setting is null', + (tester) async { + await tester.pumpWidget( + App( + data: AppData( + connectionRepository: connectionRepository, + controlRepository: controlRepository, + settingsController: settingsController, + initialConnectionData: null, + initialSetupCompleted: false, + ), + ), + ); + + final materialApp = tester.widget( + find.byType(MaterialApp), + ); + + expect(materialApp.themeMode, equals(ThemeMode.system)); + }, + ); + + testWidgets( + 'is ThemeMode.dark when darkModeOn setting is true', + (tester) async { + darkModeOn.value = true; + + await tester.pumpWidget( + App( + data: AppData( + connectionRepository: connectionRepository, + controlRepository: controlRepository, + settingsController: settingsController, + initialConnectionData: null, + initialSetupCompleted: false, + ), + ), + ); + + final materialApp = tester.widget( + find.byType(MaterialApp), + ); + + expect(materialApp.themeMode, equals(ThemeMode.dark)); + }, + ); + + testWidgets( + 'is ThemeMode.system when darkModeOn setting is false', + (tester) async { + darkModeOn.value = false; + + await tester.pumpWidget( + App( + data: AppData( + connectionRepository: connectionRepository, + controlRepository: controlRepository, + settingsController: settingsController, + initialConnectionData: null, + initialSetupCompleted: false, + ), + ), + ); + + final materialApp = tester.widget( + find.byType(MaterialApp), + ); + + expect(materialApp.themeMode, equals(ThemeMode.system)); + }, + ); + + testWidgets( + 'changes when darkModeOn setting changes', + (tester) async { + darkModeOn.value = false; + + await tester.pumpWidget( + App( + data: AppData( + connectionRepository: connectionRepository, + controlRepository: controlRepository, + settingsController: settingsController, + initialConnectionData: null, + initialSetupCompleted: false, + ), + ), + ); + + var materialApp = tester.widget( + find.byType(MaterialApp), + ); + + expect(materialApp.themeMode, equals(ThemeMode.system)); + + darkModeOn.value = true; + await tester.pump(); + + materialApp = tester.widget( + find.byType(MaterialApp), + ); + + expect(materialApp.themeMode, equals(ThemeMode.dark)); + }, + ); + }); + + group('locale', () { + final localeVariant = ValueVariant( + AppLocalizations.supportedLocales.toSet(), + ); + + testWidgets( + 'is first supported when locale setting is null', + (tester) async { + await tester.pumpWidget( + App( + data: AppData( + connectionRepository: connectionRepository, + controlRepository: controlRepository, + settingsController: settingsController, + initialConnectionData: null, + initialSetupCompleted: true, + ), + ), + ); + + final context = tester.element(find.byType(ControlPage)); + + expect( + Localizations.localeOf(context), + equals(AppLocalizations.supportedLocales.first), + ); + }, + ); + + testWidgets( + 'is equal to the locale setting', + (tester) async { + locale.value = localeVariant.currentValue; + + await tester.pumpWidget( + App( + data: AppData( + connectionRepository: connectionRepository, + controlRepository: controlRepository, + settingsController: settingsController, + initialConnectionData: null, + initialSetupCompleted: true, + ), + ), + ); + + final context = tester.element(find.byType(ControlPage)); + + expect(Localizations.localeOf(context), equals(locale.value)); + }, + variant: localeVariant, + ); + + testWidgets( + 'changes when locale setting changes', + (tester) async { + locale.value = AppLocalizations.supportedLocales.first; + + await tester.pumpWidget( + App( + data: AppData( + connectionRepository: connectionRepository, + controlRepository: controlRepository, + settingsController: settingsController, + initialConnectionData: null, + initialSetupCompleted: true, + ), + ), + ); + + var context = tester.element(find.byType(ControlPage)); + + expect(Localizations.localeOf(context), equals(locale.value)); + + locale.value = AppLocalizations.supportedLocales.last; + await tester.pump(); + + context = tester.element(find.byType(ControlPage)); + + expect(Localizations.localeOf(context), equals(locale.value)); + }, + ); + }); + + testWidgets('creates AlertMessenger', (tester) async { + await tester.pumpWidget( + App( + data: AppData( + connectionRepository: connectionRepository, + controlRepository: controlRepository, + settingsController: settingsController, + initialConnectionData: null, + initialSetupCompleted: true, + ), + ), + ); + + expect(find.byType(AlertMessenger), findsOneWidget); + }); + }); +} diff --git a/test/connection/bloc/connection_bloc_test.dart b/test/connection/bloc/connection_bloc_test.dart new file mode 100644 index 0000000..f070d15 --- /dev/null +++ b/test/connection/bloc/connection_bloc_test.dart @@ -0,0 +1,469 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:connection_repository/connection_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class _MockConnectionRepository extends Mock implements ConnectionRepository {} + +class _MockSettingsController extends Mock implements SettingsController {} + +void main() { + group('ConnectionBloc', () { + final address = IpAddressInput.dirty('192.168.1.5'); + final port = PortInput.dirty(8888); + + final exception = ConnectionException('error', StackTrace.current); + + late ConnectionRepository connectionRepository; + late SettingsController settingsController; + late StreamController statusesController; + + setUp(() { + statusesController = StreamController(); + + connectionRepository = _MockConnectionRepository(); + when(() => connectionRepository.statuses).thenAnswer( + (_) => statusesController.stream, + ); + when( + () => connectionRepository.connect( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenAnswer((_) async {}); + when(connectionRepository.disconnect).thenAnswer((_) async {}); + + settingsController = _MockSettingsController(); + when( + () => settingsController.setIpAddress( + ipAddress: any(named: 'ipAddress'), + ), + ).thenAnswer((_) async {}); + when( + () => settingsController.setPort( + port: any(named: 'port'), + ), + ).thenAnswer((_) async {}); + }); + + tearDown(() { + statusesController.close(); + }); + + group('initial state is correct', () { + test('when initialConnectionData is null', () { + final bloc = ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ); + + expect( + bloc.state, + equals( + ConnectionInitial( + address: IpAddressInput.pure(), + port: PortInput.pure(), + ), + ), + ); + }); + + test('when initialConnectionData is not null', () { + final bloc = ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + initialConnectionData: ConnectionData( + address: address.value, + port: port.value, + ), + ); + + expect( + bloc.state, + equals( + ConnectionInitial( + address: address, + port: port, + ), + ), + ); + }); + }); + + group('on IpAddressUpdated', () { + blocTest( + 'emits ConnectionInitial with updated address if not null passed', + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + act: (bloc) => bloc.add( + IpAddressUpdated(address: address.value), + ), + expect: () => [ + ConnectionInitial( + address: address, + port: PortInput.pure(), + ), + ], + ); + + blocTest( + 'emits ConnectionInitial with dirty address if null passed', + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + initialConnectionData: ConnectionData( + address: address.value, + port: port.value, + ), + ), + act: (bloc) => bloc.add( + const IpAddressUpdated(address: null), + ), + expect: () => [ + ConnectionInitial( + address: IpAddressInput.dirty(), + port: port, + ), + ], + ); + }); + + group('on PortUpdated', () { + blocTest( + 'emits ConnectionInitial with updated port if not null passed', + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + act: (bloc) => bloc.add( + PortUpdated(port: port.value), + ), + expect: () => [ + ConnectionInitial( + address: IpAddressInput.pure(), + port: port, + ), + ], + ); + + blocTest( + 'emits ConnectionInitial with dirty port if null passed', + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + initialConnectionData: ConnectionData( + address: address.value, + port: port.value, + ), + ), + act: (bloc) => bloc.add( + const PortUpdated(port: null), + ), + expect: () => [ + ConnectionInitial( + address: address, + port: PortInput.dirty(), + ), + ], + ); + }); + + group('on ConnectionDataCheckRequested', () { + blocTest( + 'emits nothing when connection data is valid', + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + seed: () => ConnectionInitial(address: address, port: port), + act: (bloc) => bloc.add( + const ConnectionDataCheckRequested(), + ), + expect: () => const [], + ); + + blocTest( + 'emits state with reset connection data when it was not valid', + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + seed: () => ConnectionInitial( + address: IpAddressInput.dirty('123'), + port: PortInput.dirty(65536), + ), + act: (bloc) => bloc.add( + const ConnectionDataCheckRequested(), + ), + expect: () => [ + ConnectionInitial( + address: IpAddressInput.pure(), + port: PortInput.pure(), + ), + ], + ); + }); + + group('on ConnectionRequested', () { + blocTest( + 'emits nothing when connection data is not valid', + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + act: (bloc) => bloc.add( + const ConnectionRequested(), + ), + expect: () => const [], + ); + + blocTest( + 'emits [ConnectionInProgress, ConnectionFailure] when connection is ' + 'not successful', + setUp: () { + when( + () => connectionRepository.connect( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenThrow(exception); + }, + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + seed: () => ConnectionInitial(address: address, port: port), + act: (bloc) => bloc.add(const ConnectionRequested()), + wait: const Duration(milliseconds: 500), + expect: () => [ + ConnectionInProgress(address: address, port: port), + ConnectionFailure(address: address, port: port), + ], + verify: (_) { + verify( + () => connectionRepository.connect( + address: address.value, + port: port.value, + ), + ).called(1); + }, + errors: () => [exception], + ); + + blocTest( + 'emits [ConnectionInProgress, ConnectionSuccess] when connection is ' + 'successful', + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + seed: () => ConnectionInitial(address: address, port: port), + act: (bloc) => bloc.add(const ConnectionRequested()), + wait: const Duration(milliseconds: 500), + expect: () => [ + ConnectionInProgress(address: address, port: port), + ConnectionSuccess(address: address, port: port), + ], + verify: (_) { + verify( + () => connectionRepository.connect( + address: address.value, + port: port.value, + ), + ).called(1); + verify(() => connectionRepository.statuses).called(1); + verify( + () => settingsController.setIpAddress(ipAddress: address.value), + ).called(1); + verify( + () => settingsController.setPort(port: port.value), + ).called(1); + }, + ); + + blocTest( + 'emits ConnectionInitial when statuses stream is closed', + setUp: () { + statusesController.onListen = () { + statusesController.close(); + }; + }, + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + seed: () => ConnectionInitial(address: address, port: port), + act: (bloc) => bloc.add(const ConnectionRequested()), + wait: const Duration(milliseconds: 500), + expect: () => [ + ConnectionInProgress(address: address, port: port), + ConnectionSuccess(address: address, port: port), + ConnectionInitial(address: address, port: port), + ], + verify: (_) { + verify( + () => connectionRepository.connect( + address: address.value, + port: port.value, + ), + ).called(1); + verify(() => connectionRepository.statuses).called(1); + verify( + () => settingsController.setIpAddress(ipAddress: address.value), + ).called(1); + verify( + () => settingsController.setPort(port: port.value), + ).called(1); + }, + ); + + blocTest( + 'emits ConnectionInProgress when statuses stream emits ' + 'ConnectionStatus.connecting', + setUp: () { + statusesController.onListen = () { + statusesController.add(ConnectionStatus.connecting); + }; + }, + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + seed: () => ConnectionInitial(address: address, port: port), + act: (bloc) => bloc.add(const ConnectionRequested()), + wait: const Duration(milliseconds: 500), + expect: () => [ + ConnectionInProgress(address: address, port: port), + ConnectionSuccess(address: address, port: port), + ConnectionInProgress(address: address, port: port), + ], + verify: (_) { + verify( + () => connectionRepository.connect( + address: address.value, + port: port.value, + ), + ).called(1); + verify(() => connectionRepository.statuses).called(1); + verify( + () => settingsController.setIpAddress(ipAddress: address.value), + ).called(1); + verify( + () => settingsController.setPort(port: port.value), + ).called(1); + }, + ); + + blocTest( + 'emits ConnectionSuccess when statuses stream emits ' + 'ConnectionStatus.connected', + setUp: () { + statusesController.onListen = () { + statusesController + ..add(ConnectionStatus.connecting) + ..add(ConnectionStatus.connected); + }; + }, + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + seed: () => ConnectionInitial(address: address, port: port), + act: (bloc) => bloc.add(const ConnectionRequested()), + wait: const Duration(milliseconds: 500), + expect: () => [ + ConnectionInProgress(address: address, port: port), + ConnectionSuccess(address: address, port: port), + ConnectionInProgress(address: address, port: port), + ConnectionSuccess(address: address, port: port), + ], + verify: (_) { + verify( + () => connectionRepository.connect( + address: address.value, + port: port.value, + ), + ).called(1); + verify(() => connectionRepository.statuses).called(1); + verify( + () => settingsController.setIpAddress(ipAddress: address.value), + ).called(1); + verify( + () => settingsController.setPort(port: port.value), + ).called(1); + }, + ); + }); + + group('on ConnectionStatusUpdated', () { + blocTest( + 'emits ConnectionInProgress when status is ConnectionStatus.connecting', + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + seed: () => ConnectionSuccess(address: address, port: port), + act: (bloc) => bloc.add( + const ConnectionStatusUpdated(status: ConnectionStatus.connecting), + ), + expect: () => [ + ConnectionInProgress(address: address, port: port), + ], + ); + + blocTest( + 'emits ConnectionSuccess when status is ConnectionStatus.connecting', + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + seed: () => ConnectionInProgress(address: address, port: port), + act: (bloc) => bloc.add( + const ConnectionStatusUpdated(status: ConnectionStatus.connected), + ), + expect: () => [ + ConnectionSuccess(address: address, port: port), + ], + ); + + blocTest( + 'emits ConnectionInitial when status is ConnectionStatus.disconnected', + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + seed: () => ConnectionSuccess(address: address, port: port), + act: (bloc) => bloc.add( + const ConnectionStatusUpdated(status: ConnectionStatus.disconnected), + ), + expect: () => [ + ConnectionInitial(address: address, port: port), + ], + ); + }); + + group('on DisconnectionRequested', () { + blocTest( + 'emits ConnectionInitial and disconnects', + build: () => ConnectionBloc( + connectionRepository: connectionRepository, + settingsController: settingsController, + ), + seed: () => ConnectionSuccess(address: address, port: port), + act: (bloc) => bloc.add(const DisconnectionRequested()), + expect: () => [ + ConnectionInitial(address: address, port: port), + ], + verify: (_) { + verify(connectionRepository.disconnect).called(1); + }, + ); + }); + }); +} diff --git a/test/connection/bloc/connection_event_test.dart b/test/connection/bloc/connection_event_test.dart new file mode 100644 index 0000000..39c318e --- /dev/null +++ b/test/connection/bloc/connection_event_test.dart @@ -0,0 +1,66 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:connection_repository/connection_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/bloc/connection_bloc.dart'; + +void main() { + group('ConnectionEvent', () { + group('ConnectionRequested', () { + test('supports equality', () { + expect( + ConnectionRequested(), + equals(ConnectionRequested()), + ); + }); + }); + + group('ConnectionRequested', () { + test('supports equality', () { + expect( + ConnectionRequested(), + equals(ConnectionRequested()), + ); + }); + }); + + group('DisconnectionRequested', () { + test('supports equality', () { + expect( + DisconnectionRequested(), + equals(DisconnectionRequested()), + ); + }); + }); + + group('IpAddressUpdated', () { + test('supports equality', () { + final a = IpAddressUpdated(address: '1'); + final b = IpAddressUpdated(address: '1'); + final c = IpAddressUpdated(address: '2'); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); + + group('PortUpdated', () { + test('supports equality', () { + final a = PortUpdated(port: 1); + final b = PortUpdated(port: 1); + final c = PortUpdated(port: 2); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); + + group('ConnectionStatusUpdated', () { + test('supports equality', () { + final a = ConnectionStatusUpdated(status: ConnectionStatus.connected); + final b = ConnectionStatusUpdated(status: ConnectionStatus.connected); + final c = ConnectionStatusUpdated(status: ConnectionStatus.connecting); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); + }); +} diff --git a/test/connection/bloc/connection_state_test.dart b/test/connection/bloc/connection_state_test.dart new file mode 100644 index 0000000..5a18467 --- /dev/null +++ b/test/connection/bloc/connection_state_test.dart @@ -0,0 +1,315 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; + +void main() { + group('ConnectionState', () { + final address1 = IpAddressInput.dirty('192.168.1.5'); + final address2 = IpAddressInput.dirty('192.168.1.6'); + final port1 = PortInput.dirty(3333); + final port2 = PortInput.dirty(8888); + + test('isConnected is true only for ConnectionSuccess', () { + expect( + ConnectionInitial(address: address1, port: port1).isConnected, + isFalse, + ); + expect( + ConnectionInProgress(address: address1, port: port1).isConnected, + isFalse, + ); + expect( + ConnectionSuccess(address: address1, port: port1).isConnected, + isTrue, + ); + expect( + ConnectionFailure(address: address1, port: port1).isConnected, + isFalse, + ); + }); + + test('isConnecting is true only for ConnectionInProgress', () { + expect( + ConnectionInitial(address: address1, port: port1).isConnecting, + isFalse, + ); + expect( + ConnectionInProgress(address: address1, port: port1).isConnecting, + isTrue, + ); + expect( + ConnectionSuccess(address: address1, port: port1).isConnecting, + isFalse, + ); + expect( + ConnectionFailure(address: address1, port: port1).isConnecting, + isFalse, + ); + }); + + test( + 'isLampDataValid returns true only when address and port are valid', + () { + expect( + ConnectionInitial(address: address1, port: port1).isLampDataValid, + isTrue, + ); + expect( + ConnectionInitial( + address: IpAddressInput.pure(), + port: port1, + ).isLampDataValid, + isFalse, + ); + expect( + ConnectionInitial( + address: address1, + port: PortInput.pure(), + ).isLampDataValid, + isFalse, + ); + expect( + ConnectionInitial( + address: IpAddressInput.pure(), + port: PortInput.pure(), + ).isLampDataValid, + isFalse, + ); + }, + ); + + test( + 'connectionData returns data only when address and port are valid', + () { + expect( + ConnectionInitial(address: address1, port: port1).connectionData, + equals( + ConnectionData(address: address1.value, port: port1.value), + ), + ); + expect( + ConnectionInitial( + address: IpAddressInput.pure(), + port: port1, + ).connectionData, + isNull, + ); + expect( + ConnectionInitial( + address: address1, + port: PortInput.pure(), + ).connectionData, + isNull, + ); + expect( + ConnectionInitial( + address: IpAddressInput.pure(), + port: PortInput.pure(), + ).connectionData, + isNull, + ); + }, + ); + + group('ConnectionInitial', () { + test('can be instantiated', () { + expect( + ConnectionInitial(address: address1, port: port1), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + ConnectionInitial(address: address1, port: port1), + equals(ConnectionInitial(address: address1, port: port1)), + ); + + expect( + ConnectionInitial(address: address1, port: port1), + isNot(equals(ConnectionInitial(address: address1, port: port2))), + ); + + expect( + ConnectionInitial(address: address1, port: port1), + isNot(equals(ConnectionInitial(address: address2, port: port1))), + ); + }); + + test('copyWith returns a new instance with copied values', () { + expect( + ConnectionInitial(address: address1, port: port1).copyWith( + address: address2, + ), + equals(ConnectionInitial(address: address2, port: port1)), + ); + + expect( + ConnectionInitial(address: address1, port: port1).copyWith( + port: port2, + ), + equals(ConnectionInitial(address: address1, port: port2)), + ); + + expect( + ConnectionInitial(address: address1, port: port1).copyWith( + address: address2, + port: port2, + ), + equals(ConnectionInitial(address: address2, port: port2)), + ); + }); + }); + + group('ConnectionInProgress', () { + test('can be instantiated', () { + expect( + ConnectionInProgress(address: address1, port: port1), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + ConnectionInProgress(address: address1, port: port1), + equals(ConnectionInProgress(address: address1, port: port1)), + ); + + expect( + ConnectionInProgress(address: address1, port: port1), + isNot(equals(ConnectionInProgress(address: address1, port: port2))), + ); + + expect( + ConnectionInProgress(address: address1, port: port1), + isNot(equals(ConnectionInProgress(address: address2, port: port1))), + ); + }); + + test('copyWith returns a new instance with copied values', () { + expect( + ConnectionInProgress(address: address1, port: port1).copyWith( + address: address2, + ), + equals(ConnectionInProgress(address: address2, port: port1)), + ); + + expect( + ConnectionInProgress(address: address1, port: port1).copyWith( + port: port2, + ), + equals(ConnectionInProgress(address: address1, port: port2)), + ); + + expect( + ConnectionInProgress(address: address1, port: port1).copyWith( + address: address2, + port: port2, + ), + equals(ConnectionInProgress(address: address2, port: port2)), + ); + }); + }); + + group('ConnectionSuccess', () { + test('can be instantiated', () { + expect( + ConnectionSuccess(address: address1, port: port1), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + ConnectionSuccess(address: address1, port: port1), + equals(ConnectionSuccess(address: address1, port: port1)), + ); + + expect( + ConnectionSuccess(address: address1, port: port1), + isNot(equals(ConnectionSuccess(address: address1, port: port2))), + ); + + expect( + ConnectionSuccess(address: address1, port: port1), + isNot(equals(ConnectionSuccess(address: address2, port: port1))), + ); + }); + + test('copyWith returns a new instance with copied values', () { + expect( + ConnectionSuccess(address: address1, port: port1).copyWith( + address: address2, + ), + equals(ConnectionSuccess(address: address2, port: port1)), + ); + + expect( + ConnectionSuccess(address: address1, port: port1).copyWith( + port: port2, + ), + equals(ConnectionSuccess(address: address1, port: port2)), + ); + + expect( + ConnectionSuccess(address: address1, port: port1).copyWith( + address: address2, + port: port2, + ), + equals(ConnectionSuccess(address: address2, port: port2)), + ); + }); + }); + + group('ConnectionFailure', () { + test('can be instantiated', () { + expect( + ConnectionFailure(address: address1, port: port1), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + ConnectionFailure(address: address1, port: port1), + equals(ConnectionFailure(address: address1, port: port1)), + ); + + expect( + ConnectionFailure(address: address1, port: port1), + isNot(equals(ConnectionFailure(address: address1, port: port2))), + ); + + expect( + ConnectionFailure(address: address1, port: port1), + isNot(equals(ConnectionFailure(address: address2, port: port1))), + ); + }); + + test('copyWith returns a new instance with copied values', () { + expect( + ConnectionFailure(address: address1, port: port1).copyWith( + address: address2, + ), + equals(ConnectionFailure(address: address2, port: port1)), + ); + + expect( + ConnectionFailure(address: address1, port: port1).copyWith( + port: port2, + ), + equals(ConnectionFailure(address: address1, port: port2)), + ); + + expect( + ConnectionFailure(address: address1, port: port1).copyWith( + address: address2, + port: port2, + ), + equals(ConnectionFailure(address: address2, port: port2)), + ); + }); + }); + }); +} diff --git a/test/connection/models/connection_data_test.dart b/test/connection/models/connection_data_test.dart new file mode 100644 index 0000000..5b20414 --- /dev/null +++ b/test/connection/models/connection_data_test.dart @@ -0,0 +1,37 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; + +void main() { + group('ConnectionData', () { + const address1 = '192.168.1.5'; + const address2 = '192.168.1.6'; + const port1 = 3333; + const port2 = 8888; + + test('can be instantiated', () { + expect( + ConnectionData(address: address1, port: port1), + isNotNull, + ); + }); + + test('supports equality', () { + expect( + ConnectionData(address: address1, port: port1), + equals(ConnectionData(address: address1, port: port1)), + ); + + expect( + ConnectionData(address: address1, port: port1), + isNot(equals(ConnectionData(address: address1, port: port2))), + ); + + expect( + ConnectionData(address: address1, port: port1), + isNot(equals(ConnectionData(address: address2, port: port1))), + ); + }); + }); +} diff --git a/test/connection/models/ip_address_input_test.dart b/test/connection/models/ip_address_input_test.dart new file mode 100644 index 0000000..e7c4b3b --- /dev/null +++ b/test/connection/models/ip_address_input_test.dart @@ -0,0 +1,37 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; + +void main() { + group('IpAddressInput', () { + test('can be instantiated', () { + expect(IpAddressInput.pure(), isNotNull); + expect(IpAddressInput.dirty(), isNotNull); + }); + + test('supports equality', () { + expect(IpAddressInput.pure(), equals(IpAddressInput.pure())); + expect(IpAddressInput.pure('1'), equals(IpAddressInput.pure('1'))); + expect(IpAddressInput.pure('1'), isNot(equals(IpAddressInput.pure('2')))); + expect(IpAddressInput.dirty(), equals(IpAddressInput.dirty())); + expect(IpAddressInput.dirty('1'), equals(IpAddressInput.dirty('1'))); + expect( + IpAddressInput.dirty('1'), + isNot(equals(IpAddressInput.dirty('2'))), + ); + }); + + test('validates address correctly', () { + expect(IpAddressInput.dirty(' ').isValid, isFalse); + expect(IpAddressInput.dirty('blah').isValid, isFalse); + expect(IpAddressInput.dirty('1.1.1.1').isValid, isTrue); + expect(IpAddressInput.dirty('192.169.1.5').isValid, isTrue); + expect(IpAddressInput.dirty('0.169.1.5').isValid, isTrue); + expect(IpAddressInput.dirty('192.0.1.5').isValid, isTrue); + expect(IpAddressInput.dirty('192.168.0.5').isValid, isTrue); + expect(IpAddressInput.dirty('192.168.1.0').isValid, isTrue); + expect(IpAddressInput.dirty('192.169.1.256').isValid, isFalse); + }); + }); +} diff --git a/test/connection/models/port_input_test.dart b/test/connection/models/port_input_test.dart new file mode 100644 index 0000000..f5d249f --- /dev/null +++ b/test/connection/models/port_input_test.dart @@ -0,0 +1,30 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; + +void main() { + group('PortInput', () { + test('can be instantiated', () { + expect(PortInput.pure(), isNotNull); + expect(PortInput.dirty(), isNotNull); + }); + + test('supports equality', () { + expect(PortInput.pure(), equals(PortInput.pure())); + expect(PortInput.pure(1), equals(PortInput.pure(1))); + expect(PortInput.pure(1), isNot(equals(PortInput.pure(2)))); + expect(PortInput.dirty(), equals(PortInput.dirty())); + expect(PortInput.dirty(1), equals(PortInput.dirty(1))); + expect(PortInput.dirty(1), isNot(equals(PortInput.dirty(2)))); + }); + + test('validates port correctly', () { + expect(PortInput.dirty(-2).isValid, isFalse); + expect(PortInput.dirty(1).isValid, isTrue); + expect(PortInput.dirty(3333).isValid, isTrue); + expect(PortInput.dirty(65535).isValid, isTrue); + expect(PortInput.dirty(65536).isValid, isFalse); + }); + }); +} diff --git a/test/connection/widgets/connect_button_test.dart b/test/connection/widgets/connect_button_test.dart new file mode 100644 index 0000000..718ee50 --- /dev/null +++ b/test/connection/widgets/connect_button_test.dart @@ -0,0 +1,282 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../helpers/helpers.dart'; + +class _MockConnectionBloc extends MockBloc + implements ConnectionBloc {} + +void main() { + group('ConnectButton', () { + final address = IpAddressInput.dirty('192.168.1.5'); + final port = PortInput.dirty(8888); + + late ConnectionBloc bloc; + + setUp(() { + bloc = _MockConnectionBloc(); + }); + + Widget buildSubject({ + Widget? subject, + }) { + return BlocProvider.value( + value: bloc, + child: subject ?? const ConnectButton.large(), + ); + } + + group('renders correctly', () { + testWidgets('when size is medium', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: IpAddressInput.pure(), + port: PortInput.pure(), + ), + ); + + await tester.pumpSubject( + buildSubject( + subject: const ConnectButton.medium(), + ), + ); + + final button = tester.widget( + find.byType(RoundedElevatedButton), + ); + + expect(button.size, equals(RoundedElevatedButtonSize.medium)); + }); + + testWidgets('when size is large', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: IpAddressInput.pure(), + port: PortInput.pure(), + ), + ); + + await tester.pumpSubject( + buildSubject( + subject: const ConnectButton.large(), + ), + ); + + final button = tester.widget( + find.byType(RoundedElevatedButton), + ); + + expect(button.size, equals(RoundedElevatedButtonSize.large)); + }); + + testWidgets( + 'when state is $ConnectionInitial and lamp data is not valid', + (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: IpAddressInput.pure(), + port: PortInput.pure(), + ), + ); + + expect(bloc.state.isLampDataValid, isFalse); + + await tester.pumpSubject(buildSubject()); + + final button = tester.widget( + find.byType(RoundedElevatedButton), + ); + + expect(button.onPressed, isNull); + expect(find.text(tester.l10n.connect), findsOneWidget); + }, + ); + + testWidgets( + 'when state is $ConnectionInitial and lamp data is valid', + (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: address, + port: port, + ), + ); + + expect(bloc.state.isLampDataValid, isTrue); + + await tester.pumpSubject(buildSubject()); + + final button = tester.widget( + find.byType(RoundedElevatedButton), + ); + + expect(button.onPressed, isNotNull); + expect(find.text(tester.l10n.connect), findsOneWidget); + }, + ); + + testWidgets('when state is $ConnectionInProgress', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInProgress( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final button = tester.widget( + find.byType(RoundedElevatedButton), + ); + + expect(button.onPressed, isNull); + expect(find.byType(CirclesWaveLoadingIndicator), findsOneWidget); + }); + + testWidgets('when state is $ConnectionFailure', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionFailure( + address: IpAddressInput.pure(), + port: PortInput.pure(), + ), + ); + + await tester.pumpSubject(buildSubject()); + + final button = tester.widget( + find.byType(RoundedElevatedButton), + ); + + expect(button.onPressed, isNull); + expect(find.text(tester.l10n.connect), findsOneWidget); + }); + + testWidgets('when state is $ConnectionSuccess', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionSuccess( + address: IpAddressInput.pure(), + port: PortInput.pure(), + ), + ); + + await tester.pumpSubject(buildSubject()); + + final button = tester.widget( + find.byType(RoundedElevatedButton), + ); + + expect(button.onPressed, isNull); + expect(find.text(tester.l10n.connect), findsOneWidget); + }); + }); + + testWidgets('on tap adds event to bloc', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + await tester.tap(find.byType(ConnectButton)); + + verify(() => bloc.add(const ConnectionRequested())).called(1); + }); + + testWidgets('rebuilds when lamp data changes', (tester) async { + final controller = StreamController(); + addTearDown(controller.close); + + whenListen( + bloc, + controller.stream, + initialState: ConnectionInitial( + address: IpAddressInput.pure(), + port: PortInput.pure(), + ), + ); + + await tester.pumpSubject(buildSubject()); + + var button = tester.widget( + find.byType(RoundedElevatedButton), + ); + + expect(button.onPressed, isNull); + + controller.add( + ConnectionInitial( + address: address, + port: port, + ), + ); + await tester.pump(); + + button = tester.widget( + find.byType(RoundedElevatedButton), + ); + + expect(button.onPressed, isNotNull); + }); + + testWidgets('rebuilds when connection is in progress', (tester) async { + final controller = StreamController(); + addTearDown(controller.close); + + whenListen( + bloc, + controller.stream, + initialState: ConnectionInitial( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + var button = tester.widget( + find.byType(RoundedElevatedButton), + ); + + expect(button.onPressed, isNotNull); + + controller.add( + ConnectionInProgress( + address: address, + port: port, + ), + ); + await tester.pump(); + + button = tester.widget( + find.byType(RoundedElevatedButton), + ); + + expect(button.onPressed, isNull); + }); + }); +} + +extension _ConnectButton on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Center(child: subject), + ), + ), + ); + } +} diff --git a/test/connection/widgets/connect_dialog_test.dart b/test/connection/widgets/connect_dialog_test.dart new file mode 100644 index 0000000..a766366 --- /dev/null +++ b/test/connection/widgets/connect_dialog_test.dart @@ -0,0 +1,176 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; + +import '../../helpers/helpers.dart'; + +class _MockConnectionBloc extends MockBloc + implements ConnectionBloc {} + +void main() { + group('ConnectDialog', () { + final address = IpAddressInput.dirty('192.168.1.5'); + final port = PortInput.dirty(8888); + + late ConnectionBloc bloc; + late MockNavigator navigator; + late MockAlertMessenger messenger; + + setUp(() { + bloc = _MockConnectionBloc(); + navigator = MockNavigator(); + messenger = MockAlertMessenger(); + }); + + Widget buildSubject() { + return BlocProvider.value( + value: bloc, + child: MockAlertMessengerProvider( + messenger: messenger, + child: MockNavigatorProvider( + navigator: navigator, + child: const ConnectDialog(), + ), + ), + ); + } + + testWidgets('renders correctly', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.text(tester.l10n.connectDialogTitle), findsOneWidget); + expect(find.byType(IpAddressField), findsOneWidget); + expect(find.byType(PortField), findsOneWidget); + expect(find.byType(ConnectButton), findsOneWidget); + }); + + testWidgets('on cancel tap works correctly', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: address, + port: port, + ), + ); + + when(navigator.maybePop).thenAnswer((_) async => true); + when(messenger.clear).thenAnswer((_) async {}); + + await tester.pumpSubject(buildSubject()); + + await tester.tap(find.text(tester.l10n.cancel)); + + verify(navigator.maybePop).called(1); + verify(messenger.clear).called(1); + verify( + () => bloc.add(const ConnectionDataCheckRequested()), + ).called(1); + }); + + testWidgets('closes when connection is established', (tester) async { + final controller = StreamController(); + addTearDown(controller.close); + + whenListen( + bloc, + controller.stream, + initialState: ConnectionInitial( + address: address, + port: port, + ), + ); + + when(navigator.maybePop).thenAnswer((_) async => true); + when(messenger.clear).thenAnswer((_) async {}); + + await tester.pumpSubject(buildSubject()); + + controller.add( + ConnectionSuccess( + address: address, + port: port, + ), + ); + await tester.pump(); + + verify(navigator.maybePop).called(1); + verify(messenger.clear).called(1); + verify( + () => bloc.add(const ConnectionDataCheckRequested()), + ).called(1); + }); + + testWidgets( + 'shows error when connection is not established', + (tester) async { + final controller = StreamController(); + addTearDown(controller.close); + + whenListen( + bloc, + controller.stream, + initialState: ConnectionInitial( + address: address, + port: port, + ), + ); + + when(navigator.maybePop).thenAnswer((_) async => true); + + when(messenger.clear).thenAnswer((_) async {}); + when( + () => messenger.showError(message: any(named: 'message')), + ).thenAnswer((_) async {}); + + await tester.pumpSubject(buildSubject()); + + controller.add( + ConnectionFailure( + address: address, + port: port, + ), + ); + await tester.pump(); + + verify( + () => messenger.showError(message: tester.l10n.connectionFailed), + ).called(1); + + verifyNever(messenger.clear); + verifyNever(navigator.maybePop); + verifyNever( + () => bloc.add(const ConnectionDataCheckRequested()), + ); + }, + ); + }); +} + +extension _ConnectDialog on WidgetTester { + Future pumpSubject( + Widget subject, + ) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Center(child: subject), + ), + ), + ); + } +} diff --git a/test/connection/widgets/connection_status_indicator_test.dart b/test/connection/widgets/connection_status_indicator_test.dart new file mode 100644 index 0000000..5ca9e1d --- /dev/null +++ b/test/connection/widgets/connection_status_indicator_test.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../helpers/helpers.dart'; + +class _MockConnectionBloc extends MockBloc + implements ConnectionBloc {} + +void main() { + group('ConnectionStatusIndicator', () { + final address = IpAddressInput.dirty('192.168.1.5'); + final port = PortInput.dirty(8888); + + late ConnectionBloc bloc; + + setUp(() { + bloc = _MockConnectionBloc(); + }); + + Widget buildSubject() { + return BlocProvider.value( + value: bloc, + child: const ConnectionStatusIndicator(), + ); + } + + group('renders correctly', () { + testWidgets('when state is $ConnectionInitial', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final badge = tester.widget( + find.byType(ConnectionStatusBadge), + ); + + expect(badge.onPressed, isNotNull); + expect(find.byType(ConnectionStatusIndicator), findsOneWidget); + expect(find.text(tester.l10n.notConnected), findsOneWidget); + }); + + testWidgets('when state is $ConnectionFailure', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionFailure( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final badge = tester.widget( + find.byType(ConnectionStatusBadge), + ); + + expect(badge.onPressed, isNotNull); + expect(find.byType(ConnectionStatusIndicator), findsOneWidget); + expect(find.text(tester.l10n.notConnected), findsOneWidget); + }); + + testWidgets('when state is $ConnectionInProgress', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInProgress( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final badge = tester.widget( + find.byType(ConnectionStatusBadge), + ); + + expect(badge.onPressed, isNull); + expect(find.byType(ConnectionStatusIndicator), findsOneWidget); + expect(find.text(tester.l10n.connecting), findsOneWidget); + }); + + testWidgets('when state is $ConnectionSuccess', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionSuccess( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final badge = tester.widget( + find.byType(ConnectionStatusBadge), + ); + + expect(badge.onPressed, isNotNull); + expect(find.byType(ConnectionStatusIndicator), findsOneWidget); + expect(find.text(tester.l10n.connected), findsOneWidget); + }); + }); + + testWidgets( + 'on tap shows $ConnectDialog when state is $ConnectionInitial', + (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + await tester.tap(find.byType(ConnectionStatusIndicator)); + await tester.pumpAndSettle(); + + expect(find.byType(ConnectDialog), findsOneWidget); + }, + ); + + testWidgets( + 'on tap shows $DisconnectDialog when state is $ConnectionSuccess', + (tester) async { + when(() => bloc.state).thenReturn( + ConnectionSuccess( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + await tester.tap(find.byType(ConnectionStatusIndicator)); + await tester.pumpAndSettle(); + + expect(find.byType(DisconnectDialog), findsOneWidget); + }, + ); + + testWidgets('rebuilds when connection state changes', (tester) async { + final controller = StreamController(); + addTearDown(controller.close); + + whenListen( + bloc, + controller.stream, + initialState: ConnectionInitial( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.text(tester.l10n.notConnected), findsOneWidget); + + controller.add( + ConnectionInProgress( + address: address, + port: port, + ), + ); + await tester.pump(); + + expect(find.text(tester.l10n.connecting), findsOneWidget); + }); + }); +} + +extension _ConnectionStatusIndicator on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Center(child: subject), + ), + ), + ); + } +} diff --git a/test/connection/widgets/disconnect_dialog_test.dart b/test/connection/widgets/disconnect_dialog_test.dart new file mode 100644 index 0000000..e5e11f9 --- /dev/null +++ b/test/connection/widgets/disconnect_dialog_test.dart @@ -0,0 +1,84 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; + +import '../../helpers/helpers.dart'; + +class _MockConnectionBloc extends MockBloc + implements ConnectionBloc {} + +void main() { + group('DisconnectDialog', () { + late ConnectionBloc bloc; + late MockNavigator navigator; + + setUp(() { + bloc = _MockConnectionBloc(); + navigator = MockNavigator(); + }); + + Widget buildSubject() { + return BlocProvider.value( + value: bloc, + child: MockNavigatorProvider( + navigator: navigator, + child: const DisconnectDialog(), + ), + ); + } + + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject(buildSubject()); + + expect(find.text(tester.l10n.disconnectDialogTitle), findsOneWidget); + expect(find.text(tester.l10n.disconnectDialogBody), findsOneWidget); + expect(find.text(tester.l10n.cancel), findsOneWidget); + expect(find.text(tester.l10n.disconnect), findsOneWidget); + }); + + testWidgets('pops on cancel tap', (tester) async { + when(navigator.maybePop).thenAnswer((_) async => true); + + await tester.pumpSubject(buildSubject()); + + await tester.tap(find.text(tester.l10n.cancel)); + + verify(navigator.maybePop).called(1); + }); + + testWidgets( + 'pops and adds event to bloc on disconnect tap', + (tester) async { + when(navigator.maybePop).thenAnswer((_) async => true); + + await tester.pumpSubject(buildSubject()); + + await tester.tap(find.text(tester.l10n.disconnect)); + + verify(navigator.maybePop).called(1); + verify( + () => bloc.add(const DisconnectionRequested()), + ).called(1); + }, + ); + }); +} + +extension _DisconnectDialog on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Center(child: subject), + ), + ), + ); + } +} diff --git a/test/connection/widgets/ip_address_field_test.dart b/test/connection/widgets/ip_address_field_test.dart new file mode 100644 index 0000000..14e2a77 --- /dev/null +++ b/test/connection/widgets/ip_address_field_test.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../helpers/helpers.dart'; + +class _MockConnectionBloc extends MockBloc + implements ConnectionBloc {} + +void main() { + group('IpAddressField', () { + final address = IpAddressInput.dirty('192.168.1.5'); + final port = PortInput.dirty(8888); + + late ConnectionBloc bloc; + + setUp(() { + bloc = _MockConnectionBloc(); + }); + + Widget buildSubject() { + return BlocProvider.value( + value: bloc, + child: const IpAddressField(), + ); + } + + group('renders correctly', () { + testWidgets('when address is not valid', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: IpAddressInput.dirty('123'), + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(LabeledInputField), findsOneWidget); + expect(find.text('123'), findsOneWidget); + expect(find.text(tester.l10n.ipErrorHint), findsOneWidget); + }); + + testWidgets('when address is valid', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(LabeledInputField), findsOneWidget); + expect(find.text(address.value), findsOneWidget); + expect(find.text(tester.l10n.ipErrorHint), findsNothing); + }); + + testWidgets('when state is $ConnectionInProgress', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInProgress( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final field = tester.widget( + find.byType(LabeledInputField), + ); + + expect(field.enabled, isFalse); + }); + }); + + testWidgets('on change adds event to bloc', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: IpAddressInput.pure(), + port: PortInput.pure(), + ), + ); + + await tester.pumpSubject(buildSubject()); + + await tester.enterText( + find.byType(IpAddressField), + address.value, + ); + + verify( + () => bloc.add(IpAddressUpdated(address: address.value)), + ).called(1); + }); + + testWidgets('rebuilds when connection is in progress', (tester) async { + final controller = StreamController(); + addTearDown(controller.close); + + whenListen( + bloc, + controller.stream, + initialState: ConnectionInitial( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + var field = tester.widget( + find.byType(LabeledInputField), + ); + + expect(field.enabled, isTrue); + + controller.add( + ConnectionInProgress( + address: address, + port: port, + ), + ); + await tester.pump(); + + field = tester.widget( + find.byType(LabeledInputField), + ); + + expect(field.enabled, isFalse); + }); + }); +} + +extension _IpAddressField on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Center(child: subject), + ), + ), + ); + } +} diff --git a/test/connection/widgets/port_field_test.dart b/test/connection/widgets/port_field_test.dart new file mode 100644 index 0000000..0561b7c --- /dev/null +++ b/test/connection/widgets/port_field_test.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../helpers/helpers.dart'; + +class _MockConnectionBloc extends MockBloc + implements ConnectionBloc {} + +void main() { + group('PortField', () { + final address = IpAddressInput.dirty('192.168.1.5'); + final port = PortInput.dirty(8888); + + late ConnectionBloc bloc; + + setUp(() { + bloc = _MockConnectionBloc(); + }); + + Widget buildSubject() { + return BlocProvider.value( + value: bloc, + child: const PortField(), + ); + } + + group('renders correctly', () { + testWidgets('when port is not valid', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: address, + port: PortInput.dirty(65536), + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(LabeledInputField), findsOneWidget); + expect(find.text('65536'), findsOneWidget); + expect(find.text(tester.l10n.portErrorHint), findsOneWidget); + }); + + testWidgets('when port is valid', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(LabeledInputField), findsOneWidget); + expect(find.text(port.value.toString()), findsOneWidget); + expect(find.text(tester.l10n.portErrorHint), findsNothing); + }); + + testWidgets('when state is $ConnectionInProgress', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInProgress( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final field = tester.widget( + find.byType(LabeledInputField), + ); + + expect(field.enabled, isFalse); + }); + }); + + testWidgets('on change adds event to bloc', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: IpAddressInput.pure(), + port: PortInput.pure(), + ), + ); + + await tester.pumpSubject(buildSubject()); + + await tester.enterText( + find.byType(PortField), + port.value.toString(), + ); + + verify( + () => bloc.add(PortUpdated(port: port.value)), + ).called(1); + }); + + testWidgets('rebuilds when connection is in progress', (tester) async { + final controller = StreamController(); + addTearDown(controller.close); + + whenListen( + bloc, + controller.stream, + initialState: ConnectionInitial( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + var field = tester.widget( + find.byType(LabeledInputField), + ); + + expect(field.enabled, isTrue); + + controller.add( + ConnectionInProgress( + address: address, + port: port, + ), + ); + await tester.pump(); + + field = tester.widget( + find.byType(LabeledInputField), + ); + + expect(field.enabled, isFalse); + }); + }); +} + +extension _PortField on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Center(child: subject), + ), + ), + ); + } +} diff --git a/test/control/bloc/control_bloc_test.dart b/test/control/bloc/control_bloc_test.dart new file mode 100644 index 0000000..b34d1d8 --- /dev/null +++ b/test/control/bloc/control_bloc_test.dart @@ -0,0 +1,1157 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:control_repository/control_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockControlRepository extends Mock implements ControlRepository {} + +void main() { + const defaultBrightness = 128; + const defaultSpeed = 30; + const defaultScale = 10; + + group('ConnectionBloc', () { + const connectionData = ConnectionData( + address: '192.168.1.5', + port: 8888, + ); + + final exception = ControlException('error', StackTrace.current); + + late ControlRepository controlRepository; + late StreamController messagesController; + + setUp(() { + messagesController = StreamController(); + + controlRepository = _MockControlRepository(); + when(() => controlRepository.messages).thenAnswer( + (_) => messagesController.stream, + ); + }); + + tearDown(() { + messagesController.close(); + }); + + setUpAll(() { + registerFallbackValue(GyverLampMode.values.first); + }); + + group('initial state is correct', () { + test('when connectionData is null', () { + final bloc = ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ); + + expect( + bloc.state, + equals( + ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: defaultBrightness, + speed: defaultSpeed, + scale: defaultScale, + isOn: false, + ), + ), + ); + }); + + test('when connectionData is not null', () { + final bloc = ControlBloc( + controlRepository: controlRepository, + isConnected: true, + connectionData: connectionData, + ); + + expect( + bloc.state, + equals( + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: defaultBrightness, + speed: defaultSpeed, + scale: defaultScale, + isOn: false, + ), + ), + ); + }); + }); + + group('on ControlRequested', () { + blocTest( + 'emits nothing when not connected', + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + act: (bloc) => bloc.add(const ControlRequested()), + expect: () => const [], + verify: (bloc) => expect(bloc.state.isConnected, isFalse), + ); + + blocTest( + 'calls ControlRepository.requestCurrentState', + setUp: () { + when( + () => controlRepository.requestCurrentState( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenAnswer((_) async => {}); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: true, + connectionData: connectionData, + ), + act: (bloc) => bloc.add(const ControlRequested()), + expect: () => const [], + verify: (_) { + verify( + () => controlRepository.requestCurrentState( + address: connectionData.address, + port: connectionData.port, + ), + ).called(1); + }, + ); + + blocTest( + 'adds error when ControlRepository.requestCurrentState throws', + setUp: () { + when( + () => controlRepository.requestCurrentState( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenThrow(exception); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: true, + connectionData: connectionData, + ), + act: (bloc) => bloc.add(const ControlRequested()), + expect: () => const [], + verify: (_) { + verify( + () => controlRepository.requestCurrentState( + address: connectionData.address, + port: connectionData.port, + ), + ).called(1); + }, + errors: () => [exception], + ); + + blocTest( + 'subscribes to the messages stream', + setUp: () { + when( + () => controlRepository.requestCurrentState( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenAnswer((_) async => {}); + + messagesController.onListen = () { + messagesController.add( + GyverLampStateChangedMessage( + mode: GyverLampMode.values.last, + brightness: 11, + speed: 22, + scale: 33, + isOn: true, + ), + ); + }; + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: true, + connectionData: connectionData, + ), + act: (bloc) => bloc.add(const ControlRequested()), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.last, + brightness: 11, + speed: 22, + scale: 33, + isOn: true, + ), + ], + ); + }); + + group('on LampMessageReceived', () { + blocTest( + 'emits updated state when messages stream emits ' + 'GyverLampStateChangedMessage', + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: true, + connectionData: connectionData, + ), + act: (bloc) => bloc.add( + LampMessageReceived( + message: GyverLampStateChangedMessage( + mode: GyverLampMode.values.last, + brightness: 11, + speed: 22, + scale: 33, + isOn: true, + ), + ), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.last, + brightness: 11, + speed: 22, + scale: 33, + isOn: true, + ), + ], + ); + + blocTest( + 'emits updated state when messages stream emits ' + 'GyverLampBrightnessChangedMessage', + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: true, + connectionData: connectionData, + ), + act: (bloc) => bloc.add( + const LampMessageReceived( + message: GyverLampBrightnessChangedMessage(brightness: 11), + ), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 11, + speed: defaultSpeed, + scale: defaultScale, + isOn: false, + ), + ], + ); + + blocTest( + 'emits updated state when messages stream emits ' + 'GyverLampSpeedChangedMessage', + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: true, + connectionData: connectionData, + ), + act: (bloc) => bloc.add( + const LampMessageReceived( + message: GyverLampSpeedChangedMessage(speed: 22), + ), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: defaultBrightness, + speed: 22, + scale: defaultScale, + isOn: false, + ), + ], + ); + + blocTest( + 'emits updated state when messages stream emits ' + 'GyverLampScaleChangedMessage', + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: true, + connectionData: connectionData, + ), + act: (bloc) => bloc.add( + const LampMessageReceived( + message: GyverLampScaleChangedMessage(scale: 33), + ), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: defaultBrightness, + speed: defaultSpeed, + scale: 33, + isOn: false, + ), + ], + ); + }); + + group('on ConnectionStateUpdated', () { + blocTest( + 'emits updated state when isConnected is false', + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: true, + connectionData: connectionData, + ), + seed: () => ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const ConnectionStateUpdated( + isConnected: false, + connectionData: null, + ), + ), + expect: () => [ + ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ], + ); + + blocTest( + 'emits updated state when isConnected is true and tries to connect', + setUp: () { + when( + () => controlRepository.requestCurrentState( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenAnswer((_) async => {}); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: true, + connectionData: connectionData, + ), + seed: () => ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const ConnectionStateUpdated( + isConnected: true, + connectionData: connectionData, + ), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ], + verify: (bloc) { + verify( + () => controlRepository.requestCurrentState( + address: connectionData.address, + port: connectionData.port, + ), + ).called(1); + }, + ); + }); + + group('on ModeUpdated', () { + blocTest( + 'only emits updated state when isConnected is false', + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + ModeUpdated(mode: GyverLampMode.values.last), + ), + expect: () => [ + ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.last, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ], + verify: (bloc) { + expect(bloc.state.isConnected, isFalse); + expect(bloc.state.connectionData, isNull); + }, + ); + + blocTest( + 'emits updated state and calls ControlRepository.setMode ' + 'when isConnected and connectionData is not null', + setUp: () { + when( + () => controlRepository.setMode( + address: any(named: 'address'), + port: any(named: 'port'), + mode: any(named: 'mode'), + ), + ).thenAnswer((_) async => {}); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + ModeUpdated(mode: GyverLampMode.values.last), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.last, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ], + verify: (bloc) { + expect(bloc.state.isConnected, isTrue); + expect(bloc.state.connectionData, isNotNull); + verify( + () => controlRepository.setMode( + address: connectionData.address, + port: connectionData.port, + mode: GyverLampMode.values.last, + ), + ).called(1); + }, + ); + + blocTest( + 'adds error when ControlRepository.setMode throws', + setUp: () { + when( + () => controlRepository.setMode( + address: any(named: 'address'), + port: any(named: 'port'), + mode: any(named: 'mode'), + ), + ).thenThrow(exception); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + ModeUpdated(mode: GyverLampMode.values.last), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.last, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ], + errors: () => [exception], + ); + }); + + group('on BrightnessUpdated', () { + blocTest( + 'only emits updated state when isConnected is false', + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const BrightnessUpdated(brightness: 33), + ), + expect: () => [ + ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: 33, + speed: 2, + scale: 3, + isOn: false, + ), + ], + verify: (bloc) { + expect(bloc.state.isConnected, isFalse); + expect(bloc.state.connectionData, isNull); + }, + ); + + blocTest( + 'emits updated state and calls ControlRepository.setBrightness ' + 'when isConnected and connectionData is not null', + setUp: () { + when( + () => controlRepository.setBrightness( + address: any(named: 'address'), + port: any(named: 'port'), + brightness: any(named: 'brightness'), + ), + ).thenAnswer((_) async => {}); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const BrightnessUpdated(brightness: 33), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 33, + speed: 2, + scale: 3, + isOn: false, + ), + ], + verify: (bloc) { + expect(bloc.state.isConnected, isTrue); + expect(bloc.state.connectionData, isNotNull); + verify( + () => controlRepository.setBrightness( + address: connectionData.address, + port: connectionData.port, + brightness: 33, + ), + ).called(1); + }, + ); + + blocTest( + 'adds error when ControlRepository.setBrightness throws', + setUp: () { + when( + () => controlRepository.setBrightness( + address: any(named: 'address'), + port: any(named: 'port'), + brightness: any(named: 'brightness'), + ), + ).thenThrow(exception); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const BrightnessUpdated(brightness: 33), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 33, + speed: 2, + scale: 3, + isOn: false, + ), + ], + errors: () => [exception], + ); + }); + + group('on SpeedUpdated', () { + blocTest( + 'only emits updated state when isConnected is false', + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const SpeedUpdated(speed: 33), + ), + expect: () => [ + ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 33, + scale: 3, + isOn: false, + ), + ], + verify: (bloc) { + expect(bloc.state.isConnected, isFalse); + expect(bloc.state.connectionData, isNull); + }, + ); + + blocTest( + 'emits updated state and calls ControlRepository.setSpeed ' + 'when isConnected and connectionData is not null', + setUp: () { + when( + () => controlRepository.setSpeed( + address: any(named: 'address'), + port: any(named: 'port'), + speed: any(named: 'speed'), + ), + ).thenAnswer((_) async => {}); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const SpeedUpdated(speed: 33), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 33, + scale: 3, + isOn: false, + ), + ], + verify: (bloc) { + expect(bloc.state.isConnected, isTrue); + expect(bloc.state.connectionData, isNotNull); + verify( + () => controlRepository.setSpeed( + address: connectionData.address, + port: connectionData.port, + speed: 33, + ), + ).called(1); + }, + ); + + blocTest( + 'adds error when ControlRepository.setSpeed throws', + setUp: () { + when( + () => controlRepository.setSpeed( + address: any(named: 'address'), + port: any(named: 'port'), + speed: any(named: 'speed'), + ), + ).thenThrow(exception); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const SpeedUpdated(speed: 33), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 33, + scale: 3, + isOn: false, + ), + ], + errors: () => [exception], + ); + }); + + group('on ScaleUpdated', () { + blocTest( + 'only emits updated state when isConnected is false', + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const ScaleUpdated(scale: 33), + ), + expect: () => [ + ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 33, + isOn: false, + ), + ], + verify: (bloc) { + expect(bloc.state.isConnected, isFalse); + expect(bloc.state.connectionData, isNull); + }, + ); + + blocTest( + 'emits updated state and calls ControlRepository.setScale ' + 'when isConnected and connectionData is not null', + setUp: () { + when( + () => controlRepository.setScale( + address: any(named: 'address'), + port: any(named: 'port'), + scale: any(named: 'scale'), + ), + ).thenAnswer((_) async => {}); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const ScaleUpdated(scale: 33), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 33, + isOn: false, + ), + ], + verify: (bloc) { + expect(bloc.state.isConnected, isTrue); + expect(bloc.state.connectionData, isNotNull); + verify( + () => controlRepository.setScale( + address: connectionData.address, + port: connectionData.port, + scale: 33, + ), + ).called(1); + }, + ); + + blocTest( + 'adds error when ControlRepository.setScale throws', + setUp: () { + when( + () => controlRepository.setScale( + address: any(named: 'address'), + port: any(named: 'port'), + scale: any(named: 'scale'), + ), + ).thenThrow(exception); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const ScaleUpdated(scale: 33), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 33, + isOn: false, + ), + ], + errors: () => [exception], + ); + }); + + group('on PowerToggled', () { + blocTest( + 'only emits updated state when isConnected is false', + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const PowerToggled(isOn: true), + ), + expect: () => [ + ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + ], + verify: (bloc) { + expect(bloc.state.isConnected, isFalse); + expect(bloc.state.connectionData, isNull); + }, + ); + + blocTest( + 'emits updated state and calls ControlRepository.turnOn ' + 'when isConnected, connectionData is not null and isOn', + setUp: () { + when( + () => controlRepository.turnOn( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenAnswer((_) async => {}); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const PowerToggled(isOn: true), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + ], + verify: (bloc) { + expect(bloc.state.isConnected, isTrue); + expect(bloc.state.connectionData, isNotNull); + verify( + () => controlRepository.turnOn( + address: connectionData.address, + port: connectionData.port, + ), + ).called(1); + }, + ); + + blocTest( + 'adds error when ControlRepository.turnOn throws', + setUp: () { + when( + () => controlRepository.turnOn( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenThrow(exception); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + act: (bloc) => bloc.add( + const PowerToggled(isOn: true), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + ], + errors: () => [exception], + ); + + blocTest( + 'emits updated state and calls ControlRepository.turnOff ' + 'when isConnected, connectionData is not null and not isOn', + setUp: () { + when( + () => controlRepository.turnOff( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenAnswer((_) async => {}); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + act: (bloc) => bloc.add( + const PowerToggled(isOn: false), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ], + verify: (bloc) { + expect(bloc.state.isConnected, isTrue); + expect(bloc.state.connectionData, isNotNull); + verify( + () => controlRepository.turnOff( + address: connectionData.address, + port: connectionData.port, + ), + ).called(1); + }, + ); + + blocTest( + 'adds error when ControlRepository.turnOff throws', + setUp: () { + when( + () => controlRepository.turnOff( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenThrow(exception); + }, + build: () => ControlBloc( + controlRepository: controlRepository, + isConnected: false, + connectionData: null, + ), + seed: () => ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + act: (bloc) => bloc.add( + const PowerToggled(isOn: false), + ), + expect: () => [ + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ], + errors: () => [exception], + ); + }); + }); +} diff --git a/test/control/bloc/control_event_test.dart b/test/control/bloc/control_event_test.dart new file mode 100644 index 0000000..c9b4b26 --- /dev/null +++ b/test/control/bloc/control_event_test.dart @@ -0,0 +1,132 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:control_repository/control_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/control/control.dart'; + +void main() { + group('ControlEvent', () { + group('ControlRequested', () { + test('supports equality', () { + expect( + ControlRequested(), + equals(ControlRequested()), + ); + }); + }); + + group('ConnectionStateUpdated', () { + test( + 'throws assertion error when connectionData is null and isConnected', + () { + expect( + () => ConnectionStateUpdated( + isConnected: true, + connectionData: null, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals('connectionData must not be null when isConnected'), + ), + ), + ); + }, + ); + + test('supports equality', () { + final a = ConnectionStateUpdated( + isConnected: true, + connectionData: ConnectionData( + address: '192.168.1.5', + port: 3333, + ), + ); + final b = ConnectionStateUpdated( + isConnected: true, + connectionData: ConnectionData( + address: '192.168.1.5', + port: 3333, + ), + ); + final c = ConnectionStateUpdated( + isConnected: true, + connectionData: ConnectionData( + address: '192.168.1.5', + port: 8888, + ), + ); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); + + group('LampMessageReceived', () { + test('supports equality', () { + final a = LampMessageReceived( + message: GyverLampScaleChangedMessage(scale: 1), + ); + final b = LampMessageReceived( + message: GyverLampScaleChangedMessage(scale: 1), + ); + final c = LampMessageReceived( + message: GyverLampScaleChangedMessage(scale: 2), + ); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); + + group('ModeUpdated', () { + test('supports equality', () { + final a = ModeUpdated(mode: GyverLampMode.cloud); + final b = ModeUpdated(mode: GyverLampMode.cloud); + final c = ModeUpdated(mode: GyverLampMode.color); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); + + group('BrightnessUpdated', () { + test('supports equality', () { + final a = BrightnessUpdated(brightness: 1); + final b = BrightnessUpdated(brightness: 1); + final c = BrightnessUpdated(brightness: 2); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); + + group('SpeedUpdated', () { + test('supports equality', () { + final a = SpeedUpdated(speed: 1); + final b = SpeedUpdated(speed: 1); + final c = SpeedUpdated(speed: 2); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); + + group('ScaleUpdated', () { + test('supports equality', () { + final a = ScaleUpdated(scale: 1); + final b = ScaleUpdated(scale: 1); + final c = ScaleUpdated(scale: 2); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); + + group('PowerToggled', () { + test('supports equality', () { + final a = PowerToggled(isOn: true); + final b = PowerToggled(isOn: true); + final c = PowerToggled(isOn: false); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + }); + }); +} diff --git a/test/control/bloc/control_state_test.dart b/test/control/bloc/control_state_test.dart new file mode 100644 index 0000000..ffb3ed7 --- /dev/null +++ b/test/control/bloc/control_state_test.dart @@ -0,0 +1,530 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:control_repository/control_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/control/control.dart'; + +void main() { + group('ControlState', () { + final connectionData1 = ConnectionData( + address: '192.168.1.5', + port: 3333, + ); + final connectionData2 = ConnectionData( + address: '192.168.1.6', + port: 8888, + ); + + test('can be instantiated', () { + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + isNotNull, + ); + }); + + test( + 'throws assertion error when connectionData is null and isConnected', + () { + expect( + () => ControlState( + isConnected: true, + connectionData: null, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals('connectionData must not be null when isConnected'), + ), + ), + ); + }, + ); + + test('supports equality', () { + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + equals( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + isNot( + equals( + ControlState( + isConnected: true, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + isNot( + equals( + ControlState( + isConnected: false, + connectionData: connectionData2, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + isNot( + equals( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.cloud, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + isNot( + equals( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 4, + speed: 2, + scale: 3, + isOn: false, + ), + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + isNot( + equals( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 4, + scale: 3, + isOn: false, + ), + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + isNot( + equals( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 4, + isOn: false, + ), + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + isNot( + equals( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + ), + ), + ); + }); + + test('copyWith returns a new instance with copied values', () { + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ).copyWith(isConnected: true), + equals( + ControlState( + isConnected: true, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ).copyWith(connectionData: connectionData2), + equals( + ControlState( + isConnected: false, + connectionData: connectionData2, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ).copyWith(mode: GyverLampMode.cloud), + equals( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.cloud, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ).copyWith(brightness: 4), + equals( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 4, + speed: 2, + scale: 3, + isOn: false, + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ).copyWith(speed: 4), + equals( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 4, + scale: 3, + isOn: false, + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ).copyWith(scale: 4), + equals( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 4, + isOn: false, + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ).copyWith(isOn: true), + equals( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: true, + ), + ), + ); + }); + + test( + 'copyWithConnectionState returns a new instance with copied values', + () { + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ).copyWithConnectionState( + isConnected: true, + connectionData: connectionData2, + ), + equals( + ControlState( + isConnected: true, + connectionData: connectionData2, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ).copyWithConnectionState( + isConnected: false, + connectionData: connectionData2, + ), + equals( + ControlState( + isConnected: false, + connectionData: connectionData2, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ).copyWithConnectionState( + isConnected: false, + connectionData: null, + ), + equals( + ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ), + ); + + expect( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ).copyWithConnectionState( + isConnected: false, + connectionData: connectionData1, + ), + equals( + ControlState( + isConnected: false, + connectionData: connectionData1, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + isOn: false, + ), + ), + ); + }, + ); + }); +} diff --git a/test/control/view/control_page_test.dart b/test/control/view/control_page_test.dart new file mode 100644 index 0000000..fe44839 --- /dev/null +++ b/test/control/view/control_page_test.dart @@ -0,0 +1,177 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:control_repository/control_repository.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:provider/provider.dart'; + +class _MockConnectionBloc extends MockBloc + implements ConnectionBloc {} + +class _MockControlRepository extends Mock implements ControlRepository {} + +void main() { + group('ControlPage', () { + const connectionData = ConnectionData( + address: '192.168.1.5', + port: 3333, + ); + + late ConnectionBloc connectionBloc; + late ControlRepository controlRepository; + late StreamController messagesController; + + setUp(() { + connectionBloc = _MockConnectionBloc(); + when(() => connectionBloc.state).thenReturn( + ConnectionInitial( + address: IpAddressInput.dirty(connectionData.address), + port: PortInput.dirty(connectionData.port), + ), + ); + + messagesController = StreamController(); + + controlRepository = _MockControlRepository(); + when(() => controlRepository.messages).thenAnswer( + (_) => messagesController.stream, + ); + }); + + Widget buildSubject() { + return BlocProvider.value( + value: connectionBloc, + child: Provider.value( + value: controlRepository, + child: const ControlPage(), + ), + ); + } + + group('route', () { + test('returns correct GyverLampPageRoute', () { + expect( + ControlPage.route(), + isRoute(whereName: equals('control')), + ); + }); + + testWidgets('can be pushed', (tester) async { + await tester.pumpWidget( + BlocProvider.value( + value: connectionBloc, + child: Provider.value( + value: controlRepository, + child: MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Center( + child: Builder( + builder: (context) { + return ElevatedButton( + child: const Text('PUSH'), + onPressed: () { + Navigator.of(context).push( + ControlPage.route(), + ); + }, + ); + }, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('PUSH')); + await tester.pumpAndSettle(); + + expect(find.byType(ControlPage), findsOneWidget); + }); + }); + + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ControlPage), findsOneWidget); + expect(find.byType(ControlView), findsOneWidget); + }); + + testWidgets('listens to the ConnectionBloc', (tester) async { + when( + () => controlRepository.requestCurrentState( + address: any(named: 'address'), + port: any(named: 'port'), + ), + ).thenAnswer((_) async => {}); + + final statesController = StreamController(); + addTearDown(statesController.close); + + whenListen( + connectionBloc, + statesController.stream, + initialState: ConnectionInitial( + address: IpAddressInput.dirty(connectionData.address), + port: PortInput.dirty(connectionData.port), + ), + ); + + await tester.pumpSubject(buildSubject()); + + statesController.add( + ConnectionSuccess( + address: IpAddressInput.dirty(connectionData.address), + port: PortInput.dirty(connectionData.port), + ), + ); + + await tester.pump(); + + verify( + () => controlRepository.requestCurrentState( + address: connectionData.address, + port: connectionData.port, + ), + ).called(1); + + final context = tester.firstElement(find.byType(ControlView)); + final controlBloc = context.read(); + + expect( + controlBloc.state, + equals( + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 128, + speed: 30, + scale: 10, + isOn: false, + ), + ), + ); + }); + }); +} + +extension _ControlPage on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: subject, + ), + ); + } +} diff --git a/test/control/view/control_view_test.dart b/test/control/view/control_view_test.dart new file mode 100644 index 0000000..6109732 --- /dev/null +++ b/test/control/view/control_view_test.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:control_repository/control_repository.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; + +import '../../helpers/helpers.dart'; + +class _MockConnectionBloc extends MockBloc + implements ConnectionBloc {} + +class _MockControlBloc extends MockBloc + implements ControlBloc {} + +void main() { + group('ControlView', () { + const connectionData = ConnectionData( + address: '192.168.1.5', + port: 3333, + ); + + late ConnectionBloc connectionBloc; + late ControlBloc controlBloc; + late MockNavigator navigator; + late MockAlertMessenger messenger; + + setUp(() { + connectionBloc = _MockConnectionBloc(); + when(() => connectionBloc.state).thenReturn( + ConnectionInitial( + address: IpAddressInput.dirty(connectionData.address), + port: PortInput.dirty(connectionData.port), + ), + ); + + controlBloc = _MockControlBloc(); + when(() => controlBloc.state).thenReturn( + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 11, + speed: 22, + scale: 33, + isOn: false, + ), + ); + + navigator = MockNavigator(); + messenger = MockAlertMessenger(); + }); + + Widget buildSubject() { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: connectionBloc), + BlocProvider.value(value: controlBloc), + ], + child: MockAlertMessengerProvider( + messenger: messenger, + child: MockNavigatorProvider( + navigator: navigator, + child: const ControlView(), + ), + ), + ); + } + + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ControlView), findsOneWidget); + expect(find.byType(ControlAppBar), findsOneWidget); + expect(find.byType(Effect), findsOneWidget); + expect(find.byType(ModePicker), findsOneWidget); + expect(find.byType(ControlRulers), findsOneWidget); + }); + + testWidgets( + 'does not show message that lamp is toggled when not isConnected', + (tester) async { + final controller = StreamController(); + addTearDown(controller.close); + + whenListen( + controlBloc, + controller.stream, + initialState: ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: 11, + speed: 22, + scale: 33, + isOn: false, + ), + ); + + await tester.pumpSubject(buildSubject()); + + controller.add( + ControlState( + isConnected: false, + connectionData: null, + mode: GyverLampMode.values.first, + brightness: 11, + speed: 22, + scale: 33, + isOn: true, + ), + ); + + verifyNever(() => messenger.showInfo(message: any(named: 'message'))); + }, + ); + + testWidgets( + 'shows message that lamp is toggled on when isConnected', + (tester) async { + when( + () => messenger.showInfo(message: any(named: 'message')), + ).thenAnswer((_) async => {}); + + final controller = StreamController(); + addTearDown(controller.close); + + whenListen( + controlBloc, + controller.stream, + initialState: ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 11, + speed: 22, + scale: 33, + isOn: false, + ), + ); + + await tester.pumpSubject(buildSubject()); + + controller.add( + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 11, + speed: 22, + scale: 33, + isOn: true, + ), + ); + + await tester.pump(); + + verify( + () => messenger.showInfo(message: tester.l10n.lampIsOn), + ).called(1); + }, + ); + + testWidgets( + 'shows message that lamp is toggled off when isConnected', + (tester) async { + when( + () => messenger.showInfo(message: any(named: 'message')), + ).thenAnswer((_) async => {}); + + final controller = StreamController(); + addTearDown(controller.close); + + whenListen( + controlBloc, + controller.stream, + initialState: ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 11, + speed: 22, + scale: 33, + isOn: true, + ), + ); + + await tester.pumpSubject(buildSubject()); + + controller.add( + ControlState( + isConnected: true, + connectionData: connectionData, + mode: GyverLampMode.values.first, + brightness: 11, + speed: 22, + scale: 33, + isOn: false, + ), + ); + + await tester.pump(); + + verify( + () => messenger.showInfo(message: tester.l10n.lampIsOff), + ).called(1); + }, + ); + }); +} + +extension _ControlView on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: subject, + ), + ); + } +} diff --git a/test/control/widgets/app_bar_test.dart b/test/control/widgets/app_bar_test.dart new file mode 100644 index 0000000..fa7e0a0 --- /dev/null +++ b/test/control/widgets/app_bar_test.dart @@ -0,0 +1,174 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:control_repository/control_repository.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; + +class _MockConnectionBloc extends MockBloc + implements ConnectionBloc {} + +class _MockControlBloc extends MockBloc + implements ControlBloc {} + +void main() { + group('ControlAppBar', () { + late ControlBloc controlBloc; + late ConnectionBloc connectionBloc; + + setUp(() { + controlBloc = _MockControlBloc(); + when(() => controlBloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + connectionBloc = _MockConnectionBloc(); + when(() => connectionBloc.state).thenReturn( + ConnectionInitial( + address: IpAddressInput.pure(), + port: PortInput.pure(), + ), + ); + }); + + Widget buildSubject() { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: controlBloc), + BlocProvider.value(value: connectionBloc), + ], + child: const ControlAppBar(), + ); + } + + test('preferredSize is correct', () { + expect( + const ControlAppBar().preferredSize, + equals(kCustomAppBarSize), + ); + }); + + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ControlAppBar), findsOneWidget); + expect(find.byType(ConnectionStatusIndicator), findsOneWidget); + expect(find.byIcon(GyverLampIcons.settings), findsOneWidget); + expect(find.byType(Switcher), findsOneWidget); + }); + + testWidgets('opens settings page on settings button tap', (tester) async { + final navigator = MockNavigator(); + when( + () => navigator.push(any()), + ).thenAnswer((_) async {}); + + await tester.pumpSubject( + MockNavigatorProvider( + navigator: navigator, + child: buildSubject(), + ), + ); + + await tester.tap( + find.ancestor( + of: find.byIcon(GyverLampIcons.settings), + matching: find.byType(FlatIconButton), + ), + ); + + verify( + () => navigator.push( + any(that: isRoute(whereName: equals('settings'))), + ), + ).called(1); + }); + + testWidgets('updates switcher when isOn changes', (tester) async { + final statesController = StreamController(); + addTearDown(statesController.close); + + whenListen( + controlBloc, + statesController.stream, + initialState: const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + var switcher = tester.widget(find.byType(Switcher)); + + expect(switcher.value, isFalse); + + statesController.add( + const ControlState( + connectionData: null, + isConnected: false, + isOn: true, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + await tester.pump(); + + switcher = tester.widget(find.byType(Switcher)); + + expect(switcher.value, isTrue); + }); + + testWidgets('adds event to bloc when switcher is toggled', (tester) async { + await tester.pumpSubject(buildSubject()); + + final switcher = tester.widget(find.byType(Switcher)); + + expect(switcher.value, isFalse); + + await tester.tap(find.byType(Switcher)); + + verify( + () => controlBloc.add(const PowerToggled(isOn: true)), + ).called(1); + }); + }); +} + +extension _ControlAppBar on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Center( + child: subject, + ), + ), + ), + ); + } +} diff --git a/test/control/widgets/effect_test.dart b/test/control/widgets/effect_test.dart new file mode 100644 index 0000000..191b1ea --- /dev/null +++ b/test/control/widgets/effect_test.dart @@ -0,0 +1,571 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:control_repository/control_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_effects/gyver_lamp_effects.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; + +class _MockControlBloc extends MockBloc + implements ControlBloc {} + +void main() { + group('Effect', () { + late ControlBloc bloc; + + setUp(() { + bloc = _MockControlBloc(); + when(() => bloc.state).thenReturn( + ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + }); + + Widget buildSubject() { + return BlocProvider.value( + value: bloc, + child: const Effect(), + ); + } + + group('renders correctly when selected mode is', () { + testWidgets('GyverLampMode.sparkles', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.sparkles, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.sparkles)); + }); + + testWidgets('GyverLampMode.fire', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.fire, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.fire)); + }); + + testWidgets('GyverLampMode.rainbowVertical', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.rainbowVertical, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.verticalRainbow)); + }); + + testWidgets('GyverLampMode.rainbowHorizontal', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.rainbowHorizontal, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.horizontalRainbow)); + }); + + testWidgets('GyverLampMode.colors', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.colors, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.colors)); + }); + + testWidgets('GyverLampMode.madness', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.madness, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.madness)); + }); + + testWidgets('GyverLampMode.cloud', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.cloud, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.clouds)); + }); + + testWidgets('GyverLampMode.lava', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.lava, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.lava)); + }); + + testWidgets('GyverLampMode.plasma', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.plasma, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.plasma)); + }); + + testWidgets('GyverLampMode.rainbow', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.rainbow, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.rainbow)); + }); + + testWidgets('GyverLampMode.rainbowStripes', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.rainbowStripes, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.rainbowStripes)); + }); + + testWidgets('GyverLampMode.zebra', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.zebra, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.zebra)); + }); + + testWidgets('GyverLampMode.forest', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.forest, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.forest)); + }); + + testWidgets('GyverLampMode.ocean', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.ocean, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.ocean)); + }); + + testWidgets('GyverLampMode.color', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.color)); + }); + + testWidgets('GyverLampMode.snow', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.snow, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.snow)); + }); + + testWidgets('GyverLampMode.matrix', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.matrix, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.matrix)); + }); + + testWidgets('GyverLampMode.fireflies', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.fireflies, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + final effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.fireflies)); + }); + }); + + testWidgets('rebuilds when mode changes', (tester) async { + final statesController = StreamController(); + addTearDown(statesController.close); + + whenListen( + bloc, + statesController.stream, + initialState: const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.sparkles, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + var effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.sparkles)); + + statesController.add( + bloc.state.copyWith(mode: GyverLampMode.fireflies), + ); + await tester.pump(); + + effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.type, equals(GyverLampEffectType.fireflies)); + }); + + testWidgets('rebuilds when speed changes', (tester) async { + final statesController = StreamController(); + addTearDown(statesController.close); + + whenListen( + bloc, + statesController.stream, + initialState: const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.sparkles, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + var effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.speed, equals(2)); + + statesController.add( + bloc.state.copyWith(speed: 33), + ); + await tester.pump(); + + effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.speed, equals(33)); + }); + + testWidgets('rebuilds when scale changes', (tester) async { + final statesController = StreamController(); + addTearDown(statesController.close); + + whenListen( + bloc, + statesController.stream, + initialState: const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.sparkles, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + var effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.scale, equals(3)); + + statesController.add( + bloc.state.copyWith(scale: 33), + ); + await tester.pump(); + + effect = tester.widget( + find.byType(GyverLampEffect), + ); + + expect(effect.scale, equals(33)); + }); + }); +} + +extension _Effect on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Center( + child: subject, + ), + ), + ), + ); + } +} diff --git a/test/control/widgets/mode_picker_test.dart b/test/control/widgets/mode_picker_test.dart new file mode 100644 index 0000000..8f1dd49 --- /dev/null +++ b/test/control/widgets/mode_picker_test.dart @@ -0,0 +1,483 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:control_repository/control_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; + +import '../../helpers/helpers.dart'; + +class _MockControlBloc extends MockBloc + implements ControlBloc {} + +void main() { + group('ModePicker', () { + late ControlBloc bloc; + + setUp(() { + bloc = _MockControlBloc(); + when(() => bloc.state).thenReturn( + ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + }); + + Widget buildSubject() { + return BlocProvider.value( + value: bloc, + child: const ModePicker(), + ); + } + + group('renders correctly when selected mode is', () { + testWidgets('GyverLampMode.sparkles', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.sparkles, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.sparklesMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.fire', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.fire, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.fireMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.rainbowVertical', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.rainbowVertical, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.rainbowVerticalMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.rainbowHorizontal', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.rainbowHorizontal, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.rainbowHorizontalMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.colors', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.colors, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.colorsMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.madness', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.madness, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.madnessMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.cloud', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.cloud, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.cloudsMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.lava', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.lava, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.lavaMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.plasma', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.plasma, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.plasmaMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.rainbow', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.rainbow, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.rainbowMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.rainbowStripes', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.rainbowStripes, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.rainbowStripesMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.zebra', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.zebra, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.zebraMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.forest', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.forest, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.forestMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.ocean', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.ocean, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.oceanMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.color', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.color, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.colorMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.snow', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.snow, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.snowMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.matrix', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.matrix, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.matrixMode), findsOneWidget); + }); + + testWidgets('GyverLampMode.fireflies', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.fireflies, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ModePicker), findsOneWidget); + expect(find.text(tester.l10n.firefliesMode), findsOneWidget); + }); + }); + + testWidgets('rebuilds when state changes', (tester) async { + final statesController = StreamController(); + addTearDown(statesController.close); + + whenListen( + bloc, + statesController.stream, + initialState: ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + var dropdown = tester.widget>( + find.byType(CustomDropdownButton), + ); + + expect(dropdown.selected, equals(GyverLampMode.values.first)); + + statesController.add( + ControlState( + connectionData: null, + isConnected: false, + isOn: true, + mode: GyverLampMode.values.last, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + await tester.pump(); + + dropdown = tester.widget>( + find.byType(CustomDropdownButton), + ); + + expect(dropdown.selected, equals(GyverLampMode.values.last)); + }); + + testWidgets('adds event to bloc when new mode is selected', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.sparkles, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + await tester.tap(find.byType(ModePicker)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text(tester.l10n.firefliesMode), + 50, + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text(tester.l10n.firefliesMode)); + await tester.tap(find.text(tester.l10n.firefliesMode)); + await tester.pump(); + + verify( + () => bloc.add( + const ModeUpdated(mode: GyverLampMode.fireflies), + ), + ).called(1); + }); + }); +} + +extension _ModePicker on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Center( + child: subject, + ), + ), + ), + ); + } +} diff --git a/test/control/widgets/rulers_test.dart b/test/control/widgets/rulers_test.dart new file mode 100644 index 0000000..3ef5223 --- /dev/null +++ b/test/control/widgets/rulers_test.dart @@ -0,0 +1,266 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:control_repository/control_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/control/control.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; + +import '../../helpers/helpers.dart'; + +class _MockControlBloc extends MockBloc + implements ControlBloc {} + +void main() { + group('ControlRulers', () { + late ControlBloc bloc; + + setUp(() { + bloc = _MockControlBloc(); + when(() => bloc.state).thenReturn( + ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.values.first, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + }); + + Widget buildSubject() { + return BlocProvider.value( + value: bloc, + child: const ControlRulers(), + ); + } + + testWidgets('renders correctly', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.fireflies, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(ControlRulers), findsOneWidget); + expect(find.byIcon(GyverLampIcons.sun), findsOneWidget); + expect(find.text(tester.l10n.brightness), findsOneWidget); + expect(find.text('1'), findsOneWidget); + expect(find.byIcon(GyverLampIcons.speed), findsOneWidget); + expect(find.text(tester.l10n.speed), findsOneWidget); + expect(find.text('2'), findsOneWidget); + expect(find.byIcon(GyverLampIcons.scale), findsOneWidget); + expect(find.text(tester.l10n.scale), findsOneWidget); + expect(find.text('3'), findsOneWidget); + expect(find.byType(Ruler), findsNWidgets(3)); + }); + + testWidgets('rebuilds when brightness changes', (tester) async { + final statesController = StreamController(); + addTearDown(statesController.close); + + whenListen( + bloc, + statesController.stream, + initialState: const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.sparkles, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.text('1'), findsOneWidget); + + statesController.add( + bloc.state.copyWith(brightness: 33), + ); + await tester.pumpAndSettle(); + + expect(find.text('1'), findsNothing); + expect(find.text('33'), findsOneWidget); + }); + + testWidgets('rebuilds when speed changes', (tester) async { + final statesController = StreamController(); + addTearDown(statesController.close); + + whenListen( + bloc, + statesController.stream, + initialState: const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.sparkles, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.text('2'), findsOneWidget); + + statesController.add( + bloc.state.copyWith(speed: 33), + ); + await tester.pumpAndSettle(); + + expect(find.text('2'), findsNothing); + expect(find.text('33'), findsOneWidget); + }); + + testWidgets('rebuilds when scale changes', (tester) async { + final statesController = StreamController(); + addTearDown(statesController.close); + + whenListen( + bloc, + statesController.stream, + initialState: const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.sparkles, + brightness: 1, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.text('3'), findsOneWidget); + + statesController.add( + bloc.state.copyWith(scale: 33), + ); + await tester.pumpAndSettle(); + + expect(find.text('3'), findsNothing); + expect(find.text('33'), findsOneWidget); + }); + + testWidgets( + 'adds event to bloc when brightness is changed', + (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.fireflies, + brightness: 33, + speed: 2, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + await tester.timedDrag( + find.widgetWithText(Ruler, '33'), + const Offset(-(kGapWidth + kMarkWidth), 0), + const Duration(milliseconds: 250), + ); + await tester.pump(); + + verify( + () => bloc.add(const BrightnessUpdated(brightness: 34)), + ).called(1); + }, + ); + + testWidgets('adds event to bloc when speed is changed', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.fireflies, + brightness: 1, + speed: 33, + scale: 3, + ), + ); + + await tester.pumpSubject(buildSubject()); + + await tester.timedDrag( + find.widgetWithText(Ruler, '33'), + const Offset(-(kGapWidth + kMarkWidth), 0), + const Duration(milliseconds: 250), + ); + await tester.pump(); + + verify( + () => bloc.add(const SpeedUpdated(speed: 34)), + ).called(1); + }); + + testWidgets('adds event to bloc when scale is changed', (tester) async { + when(() => bloc.state).thenReturn( + const ControlState( + connectionData: null, + isConnected: false, + isOn: false, + mode: GyverLampMode.fireflies, + brightness: 1, + speed: 2, + scale: 33, + ), + ); + + await tester.pumpSubject(buildSubject()); + + await tester.timedDrag( + find.widgetWithText(Ruler, '33'), + const Offset(-(kGapWidth + kMarkWidth), 0), + const Duration(milliseconds: 250), + ); + await tester.pump(); + + verify( + () => bloc.add(const ScaleUpdated(scale: 34)), + ).called(1); + }); + }); +} + +extension _ControlRulers on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Center( + child: subject, + ), + ), + ), + ); + } +} diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart new file mode 100644 index 0000000..42a1c2f --- /dev/null +++ b/test/helpers/helpers.dart @@ -0,0 +1,2 @@ +export 'mock_alert_messenger.dart'; +export 'tester_l10n.dart'; diff --git a/test/helpers/mock_alert_messenger.dart b/test/helpers/mock_alert_messenger.dart new file mode 100644 index 0000000..2ebd7ac --- /dev/null +++ b/test/helpers/mock_alert_messenger.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; + +class MockAlertMessengerProvider extends AlertMessenger { + const MockAlertMessengerProvider({ + required this.messenger, + required super.child, + super.key, + }); + + final MockAlertMessenger messenger; + + @override + AlertMessengerState createState() { + // ignore: no_logic_in_create_state + return _MockAlertMessengerState(messenger: messenger); + } +} + +class MockAlertMessenger extends Mock + with _MockAlertMessengerDiagnosticsMixin + implements AlertMessengerState {} + +class _MockAlertMessengerState extends State + with SingleTickerProviderStateMixin + implements AlertMessengerState { + _MockAlertMessengerState({ + required this.messenger, + }); + + final MockAlertMessenger messenger; + + @override + Widget build(BuildContext context) { + return widget.child; + } + + @override + Future showError({ + required String message, + }) async { + return messenger.showError(message: message); + } + + @override + Future showInfo({ + required String message, + }) async { + return messenger.showInfo(message: message); + } + + @override + Future hide() async { + return messenger.hide(); + } + + @override + void clear() { + messenger.clear(); + } +} + +mixin _MockAlertMessengerDiagnosticsMixin on Object { + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return super.toString(); + } +} diff --git a/test/helpers/tester_l10n.dart b/test/helpers/tester_l10n.dart new file mode 100644 index 0000000..f845ada --- /dev/null +++ b/test/helpers/tester_l10n.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; + +extension TesterL10n on WidgetTester { + AppLocalizations get l10n { + final app = widget(find.byType(MaterialApp)); + + final locale = app.locale ?? app.supportedLocales.first; + + return lookupAppLocalizations(locale); + } +} diff --git a/test/initial_setup/view/initial_setup_page_test.dart b/test/initial_setup/view/initial_setup_page_test.dart new file mode 100644 index 0000000..3427176 --- /dev/null +++ b/test/initial_setup/view/initial_setup_page_test.dart @@ -0,0 +1,57 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/initial_setup/initial_setup.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; + +class _MockConnectionBloc extends MockBloc + implements ConnectionBloc {} + +void main() { + group('InitialSetupPage', () { + final address = IpAddressInput.dirty('192.168.1.5'); + final port = PortInput.dirty(8888); + + late ConnectionBloc bloc; + + setUp(() { + bloc = _MockConnectionBloc(); + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: address, + port: port, + ), + ); + }); + + Widget buildSubject() { + return BlocProvider.value( + value: bloc, + child: const InitialSetupPage(), + ); + } + + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject(buildSubject()); + + expect(find.byType(InitialSetupPage), findsOneWidget); + expect(find.byType(InitialSetupView), findsOneWidget); + }); + }); +} + +extension _InitialSetupPage on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: subject, + ), + ); + } +} diff --git a/test/initial_setup/view/initial_setup_view_test.dart b/test/initial_setup/view/initial_setup_view_test.dart new file mode 100644 index 0000000..a0a544b --- /dev/null +++ b/test/initial_setup/view/initial_setup_view_test.dart @@ -0,0 +1,230 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/initial_setup/initial_setup.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:provider/provider.dart'; +import 'package:settings_controller/settings_controller.dart'; + +import '../../helpers/helpers.dart'; + +class _MockConnectionBloc extends MockBloc + implements ConnectionBloc {} + +class _MockSettingsController extends Mock implements SettingsController {} + +void main() { + group('InitialSetupView', () { + final address = IpAddressInput.dirty('192.168.1.5'); + final port = PortInput.dirty(8888); + + late ConnectionBloc bloc; + late MockNavigator navigator; + late MockAlertMessenger messenger; + late SettingsController settingsController; + + setUp(() { + bloc = _MockConnectionBloc(); + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: address, + port: port, + ), + ); + + navigator = MockNavigator(); + messenger = MockAlertMessenger(); + settingsController = _MockSettingsController(); + }); + + Widget buildSubject() { + return BlocProvider.value( + value: bloc, + child: Provider.value( + value: settingsController, + child: MockAlertMessengerProvider( + messenger: messenger, + child: MockNavigatorProvider( + navigator: navigator, + child: const InitialSetupView(), + ), + ), + ), + ); + } + + group('renders correctly', () { + testWidgets('when state is $ConnectionInitial', (tester) async { + await tester.pumpSubject(buildSubject()); + + expect(find.byType(InitialSetupView), findsOneWidget); + expect(find.byType(CustomAppBar), findsOneWidget); + expect(find.byType(InitialSetupForm), findsOneWidget); + expect(find.byType(InitialSetupBottomBar), findsOneWidget); + expect(find.text(tester.l10n.skip), findsOneWidget); + + final button = tester.widget( + find.ancestor( + of: find.text(tester.l10n.skip), + matching: find.byType(FlatTextButton), + ), + ); + + expect(button.onPressed, isNotNull); + }); + + testWidgets('when state is $ConnectionInProgress', (tester) async { + when(() => bloc.state).thenReturn( + ConnectionInProgress( + address: address, + port: port, + ), + ); + + await tester.pumpSubject(buildSubject()); + + expect(find.byType(InitialSetupView), findsOneWidget); + expect(find.byType(CustomAppBar), findsOneWidget); + expect(find.byType(InitialSetupForm), findsOneWidget); + expect(find.byType(InitialSetupBottomBar), findsOneWidget); + expect(find.text(tester.l10n.skip), findsOneWidget); + + final button = tester.widget( + find.ancestor( + of: find.text(tester.l10n.skip), + matching: find.byType(FlatTextButton), + ), + ); + + expect(button.onPressed, isNull); + }); + }); + + testWidgets( + 'clears alerts, sets setting, adds event to bloc and navigates to the ' + 'ControlPage when skip is pressed', + (tester) async { + when(messenger.clear).thenAnswer((_) async {}); + when( + () => settingsController.setInitialSetupCompleted( + completed: any(named: 'completed'), + ), + ).thenAnswer((_) async {}); + when( + () => navigator.pushReplacement(any()), + ).thenAnswer((_) async {}); + + await tester.pumpSubject(buildSubject()); + + await tester.tap(find.text(tester.l10n.skip)); + + verify(messenger.clear).called(1); + verify( + () => settingsController.setInitialSetupCompleted(completed: true), + ).called(1); + verify( + () => bloc.add(const ConnectionDataCheckRequested()), + ).called(1); + verify( + () => navigator.pushReplacement( + any(that: isRoute(whereName: equals('control'))), + ), + ).called(1); + }, + ); + + testWidgets( + 'clears alerts, sets setting, adds event to bloc and navigates to the ' + 'ControlPage when ConnectionSuccess is emitted', + (tester) async { + final controller = StreamController(); + addTearDown(controller.close); + + whenListen( + bloc, + controller.stream, + initialState: ConnectionInProgress( + address: address, + port: port, + ), + ); + + when(messenger.clear).thenAnswer((_) async {}); + when( + () => settingsController.setInitialSetupCompleted( + completed: any(named: 'completed'), + ), + ).thenAnswer((_) async {}); + when( + () => navigator.pushReplacement(any()), + ).thenAnswer((_) async {}); + + await tester.pumpSubject(buildSubject()); + + controller.add(ConnectionSuccess(address: address, port: port)); + await tester.pump(); + + verify(messenger.clear).called(1); + verify( + () => settingsController.setInitialSetupCompleted(completed: true), + ).called(1); + verify( + () => bloc.add(const ConnectionDataCheckRequested()), + ).called(1); + verify( + () => navigator.pushReplacement( + any(that: isRoute(whereName: equals('control'))), + ), + ).called(1); + }, + ); + + testWidgets( + 'shows error when $ConnectionFailure is emitted', + (tester) async { + final controller = StreamController(); + addTearDown(controller.close); + + whenListen( + bloc, + controller.stream, + initialState: ConnectionInProgress( + address: address, + port: port, + ), + ); + + when( + () => messenger.showError(message: any(named: 'message')), + ).thenAnswer((_) async {}); + + await tester.pumpSubject(buildSubject()); + + controller.add(ConnectionFailure(address: address, port: port)); + await tester.pump(); + + verify( + () => messenger.showError(message: tester.l10n.connectionFailed), + ).called(1); + }, + ); + }); +} + +extension _InitialSetupView on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: subject, + ), + ); + } +} diff --git a/test/initial_setup/widgets/bottom_bar_test.dart b/test/initial_setup/widgets/bottom_bar_test.dart new file mode 100644 index 0000000..bfea920 --- /dev/null +++ b/test/initial_setup/widgets/bottom_bar_test.dart @@ -0,0 +1,59 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/initial_setup/initial_setup.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockConnectionBloc extends MockBloc + implements ConnectionBloc {} + +void main() { + group('InitialSetupBottomBar', () { + final address = IpAddressInput.dirty('192.168.1.5'); + final port = PortInput.dirty(8888); + + late ConnectionBloc bloc; + + setUp(() { + bloc = _MockConnectionBloc(); + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: address, + port: port, + ), + ); + }); + + Widget buildSubject() { + return BlocProvider.value( + value: bloc, + child: const InitialSetupBottomBar(), + ); + } + + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject(buildSubject()); + + expect(find.byType(InitialSetupBottomBar), findsOneWidget); + expect(find.byType(ConnectButton), findsOneWidget); + }); + }); +} + +extension _InitialSetupBottomBar on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + bottomNavigationBar: subject, + ), + ), + ); + } +} diff --git a/test/initial_setup/widgets/form_test.dart b/test/initial_setup/widgets/form_test.dart new file mode 100644 index 0000000..c614cea --- /dev/null +++ b/test/initial_setup/widgets/form_test.dart @@ -0,0 +1,67 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart' hide ConnectionState; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/connection/connection.dart'; +import 'package:gyver_lamp/initial_setup/initial_setup.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../helpers/helpers.dart'; + +class _MockConnectionBloc extends MockBloc + implements ConnectionBloc {} + +void main() { + group('InitialSetupForm', () { + final address = IpAddressInput.dirty('192.168.1.5'); + final port = PortInput.dirty(8888); + + late ConnectionBloc bloc; + + setUp(() { + bloc = _MockConnectionBloc(); + when(() => bloc.state).thenReturn( + ConnectionInitial( + address: address, + port: port, + ), + ); + }); + + Widget buildSubject() { + return BlocProvider.value( + value: bloc, + child: const InitialSetupForm(), + ); + } + + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject(buildSubject()); + + expect(find.byType(InitialSetupForm), findsOneWidget); + expect(find.text(tester.l10n.initialSetupPageTitle), findsOneWidget); + expect( + find.text(tester.l10n.initialSetupFormDescription), + findsOneWidget, + ); + expect(find.byType(IpAddressField), findsOneWidget); + expect(find.byType(PortField), findsOneWidget); + }); + }); +} + +extension _InitialSetupForm on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: subject, + ), + ), + ); + } +} diff --git a/test/settings/view/settings_page_test.dart b/test/settings/view/settings_page_test.dart new file mode 100644 index 0000000..d4d6362 --- /dev/null +++ b/test/settings/view/settings_page_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp/settings/view/view.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:provider/provider.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class _MockSettingsController extends Mock implements SettingsController {} + +void main() { + group('SettingsPage', () { + late ValueNotifier locale; + late ValueNotifier darkModeOn; + late SettingsController settingsController; + + setUp(() { + locale = ValueNotifier(null); + darkModeOn = ValueNotifier(null); + + settingsController = _MockSettingsController(); + when(() => settingsController.locale).thenReturn(locale); + when(() => settingsController.darkModeOn).thenReturn(darkModeOn); + }); + + tearDown(() { + locale.dispose(); + darkModeOn.dispose(); + }); + + Widget buildSubject() { + return Provider.value( + value: settingsController, + child: const SettingsPage(), + ); + } + + group('route', () { + test('returns correct GyverLampPageRoute', () { + expect( + SettingsPage.route(), + isRoute(whereName: equals('settings')), + ); + }); + + testWidgets('can be pushed', (tester) async { + await tester.pumpWidget( + Provider.value( + value: settingsController, + child: MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Center( + child: Builder( + builder: (context) { + return ElevatedButton( + child: const Text('PUSH'), + onPressed: () { + Navigator.of(context).push( + SettingsPage.route(), + ); + }, + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('PUSH')); + await tester.pumpAndSettle(); + + expect(find.byType(SettingsPage), findsOneWidget); + }); + }); + + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject(buildSubject()); + + expect(find.byType(SettingsPage), findsOneWidget); + expect(find.byType(SettingsView), findsOneWidget); + }); + }); +} + +extension _SettingsPage on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: subject, + ), + ); + } +} diff --git a/test/settings/view/settings_view_test.dart b/test/settings/view/settings_view_test.dart new file mode 100644 index 0000000..c939d7e --- /dev/null +++ b/test/settings/view/settings_view_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp/settings/settings.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:provider/provider.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class _MockSettingsController extends Mock implements SettingsController {} + +void main() { + group('SettingView', () { + late ValueNotifier locale; + late ValueNotifier darkModeOn; + late SettingsController settingsController; + late MockNavigator navigator; + + setUp(() { + locale = ValueNotifier(null); + darkModeOn = ValueNotifier(null); + + settingsController = _MockSettingsController(); + when(() => settingsController.locale).thenReturn(locale); + when(() => settingsController.darkModeOn).thenReturn(darkModeOn); + + navigator = MockNavigator(); + }); + + tearDown(() { + locale.dispose(); + darkModeOn.dispose(); + }); + + Widget buildSubject() { + return Provider.value( + value: settingsController, + child: MockNavigatorProvider( + navigator: navigator, + child: const SettingsView(), + ), + ); + } + + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject(buildSubject()); + + expect(find.byType(SettingsView), findsOneWidget); + expect( + find.ancestor( + of: find.byIcon(GyverLampIcons.arrow_left), + matching: find.byType(FlatIconButton), + ), + findsOneWidget, + ); + expect(find.byType(SingleChildScrollView), findsOneWidget); + expect(find.byType(GeneralSettings), findsOneWidget); + expect(find.byType(GetInTouchSettings), findsOneWidget); + expect(find.byType(OtherSettings), findsOneWidget); + }); + + testWidgets('pop page on back button tap', (tester) async { + when(navigator.pop).thenAnswer((_) async {}); + + await tester.pumpSubject(buildSubject()); + + await tester.tap( + find.ancestor( + of: find.byIcon(GyverLampIcons.arrow_left), + matching: find.byType(FlatIconButton), + ), + ); + + verify(navigator.pop).called(1); + }); + }); +} + +extension _SettingsView on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: subject, + ), + ); + } +} diff --git a/test/settings/widgets/dark_mode_switcher_test.dart b/test/settings/widgets/dark_mode_switcher_test.dart new file mode 100644 index 0000000..e151bc2 --- /dev/null +++ b/test/settings/widgets/dark_mode_switcher_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/settings/widgets/widgets.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:provider/provider.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class _MockSettingsController extends Mock implements SettingsController {} + +void main() { + group('DarkModeSwitcher', () { + late ValueNotifier darkModeOn; + late SettingsController settingsController; + + setUp(() { + darkModeOn = ValueNotifier(null); + + settingsController = _MockSettingsController(); + when(() => settingsController.darkModeOn).thenReturn(darkModeOn); + when( + () => settingsController.setDarkModeOn(active: any(named: 'active')), + ).thenReturn(null); + }); + + tearDown(() { + darkModeOn.dispose(); + }); + + testWidgets('renders correctly', (WidgetTester tester) async { + await tester.pumpSubject( + controller: settingsController, + subject: const DarkModeSwitcher(), + ); + + expect(find.byType(DarkModeSwitcher), findsOneWidget); + expect(find.byType(Switcher), findsOneWidget); + }); + + testWidgets( + 'toggled off when setting is not set', + (WidgetTester tester) async { + darkModeOn.value = null; + + await tester.pumpSubject( + controller: settingsController, + subject: const DarkModeSwitcher(), + ); + + final switcher = tester.widget(find.byType(Switcher)); + expect(switcher.value, isFalse); + }, + ); + + testWidgets( + 'toggled off when setting is disabled', + (WidgetTester tester) async { + darkModeOn.value = false; + + await tester.pumpSubject( + controller: settingsController, + subject: const DarkModeSwitcher(), + ); + + final switcher = tester.widget(find.byType(Switcher)); + expect(switcher.value, isFalse); + }, + ); + + testWidgets( + 'toggled on when setting is enabled', + (WidgetTester tester) async { + darkModeOn.value = true; + + await tester.pumpSubject( + controller: settingsController, + subject: const DarkModeSwitcher(), + ); + + final switcher = tester.widget(find.byType(Switcher)); + expect(switcher.value, isTrue); + }, + ); + + testWidgets('redraws when setting is changed', (WidgetTester tester) async { + darkModeOn.value = false; + + await tester.pumpSubject( + controller: settingsController, + subject: const DarkModeSwitcher(), + ); + + darkModeOn.value = true; + await tester.pump(); + + final switcher = tester.widget(find.byType(Switcher)); + expect(switcher.value, isTrue); + }); + + testWidgets( + 'calls SettingsController.setDarkModeOn when toggled on', + (WidgetTester tester) async { + darkModeOn.value = false; + + await tester.pumpSubject( + controller: settingsController, + subject: const DarkModeSwitcher(), + ); + + await tester.tap(find.byType(DarkModeSwitcher)); + + verify(() => settingsController.setDarkModeOn(active: true)).called(1); + }, + ); + + testWidgets( + 'calls SettingsController.setDarkModeOn when toggled off', + (WidgetTester tester) async { + darkModeOn.value = true; + + await tester.pumpSubject( + controller: settingsController, + subject: const DarkModeSwitcher(), + ); + + await tester.tap(find.byType(DarkModeSwitcher)); + + verify(() => settingsController.setDarkModeOn(active: false)).called(1); + }, + ); + }); +} + +extension _DarkModeSwitcher on WidgetTester { + Future pumpSubject({ + required SettingsController controller, + required Widget subject, + }) { + return pumpWidget( + Provider.value( + value: controller, + child: MaterialApp( + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center(child: subject), + ), + ), + ), + ); + } +} diff --git a/test/settings/widgets/general_settings_test.dart b/test/settings/widgets/general_settings_test.dart new file mode 100644 index 0000000..4d1db66 --- /dev/null +++ b/test/settings/widgets/general_settings_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp/settings/settings.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:provider/provider.dart'; +import 'package:settings_controller/settings_controller.dart'; + +import '../../helpers/helpers.dart'; + +class _MockSettingsController extends Mock implements SettingsController {} + +void main() { + group('GeneralSettings', () { + late ValueNotifier locale; + late ValueNotifier darkModeOn; + late SettingsController settingsController; + + setUp(() { + locale = ValueNotifier(null); + darkModeOn = ValueNotifier(null); + + settingsController = _MockSettingsController(); + when(() => settingsController.locale).thenReturn(locale); + when(() => settingsController.darkModeOn).thenReturn(darkModeOn); + }); + + tearDown(() { + locale.dispose(); + darkModeOn.dispose(); + }); + + Widget buildSubject() { + return Provider.value( + value: settingsController, + child: const GeneralSettings(), + ); + } + + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject(buildSubject()); + + expect(find.byType(SettingTileGroup), findsOneWidget); + expect(find.text(tester.l10n.general), findsOneWidget); + + expect(find.byType(SettingTile), findsNWidgets(2)); + + expect(find.byIcon(GyverLampIcons.language), findsOneWidget); + expect(find.text(tester.l10n.language), findsOneWidget); + expect(find.byType(LanguageSelector), findsOneWidget); + + expect(find.byIcon(GyverLampIcons.moon), findsOneWidget); + expect(find.text(tester.l10n.darkMode), findsOneWidget); + expect(find.byType(DarkModeSwitcher), findsOneWidget); + }); + }); +} + +extension _GeneralSettings on WidgetTester { + Future pumpSubject(Widget subject) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: subject, + ), + ); + } +} diff --git a/test/settings/widgets/get_in_touch_settings_test.dart b/test/settings/widgets/get_in_touch_settings_test.dart new file mode 100644 index 0000000..7f2c3b5 --- /dev/null +++ b/test/settings/widgets/get_in_touch_settings_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp/settings/settings.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('GetInTouchSettings', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject((_) async {}); + + expect(find.byType(SettingTileGroup), findsOneWidget); + expect(find.text(tester.l10n.getInTouch), findsOneWidget); + + expect(find.byType(SettingTile), findsNWidgets(3)); + expect(find.byType(FlatIconButton), findsNWidgets(3)); + expect(find.byIcon(GyverLampIcons.arrow_outward), findsNWidgets(3)); + + expect(find.byIcon(GyverLampIcons.mail), findsOneWidget); + expect(find.text(tester.l10n.email), findsOneWidget); + + expect(find.byIcon(GyverLampIcons.x), findsOneWidget); + expect(find.text(tester.l10n.twitter), findsOneWidget); + + expect(find.byIcon(GyverLampIcons.dribbble), findsOneWidget); + expect(find.text(tester.l10n.dribbble), findsOneWidget); + }); + + testWidgets('launches mail on mail button tap', (tester) async { + String? launchedUrl; + + await tester.pumpSubject((url) async { + launchedUrl = url; + }); + + await tester.tap( + find.descendant( + of: find.widgetWithText(SettingTile, tester.l10n.email), + matching: find.byType(FlatIconButton), + ), + ); + + expect(launchedUrl, equals('mailto:sokolovskyi.konstantin@gmail.com')); + }); + + testWidgets('launches twitter on twitter button tap', (tester) async { + String? launchedUrl; + + await tester.pumpSubject((url) async { + launchedUrl = url; + }); + + await tester.tap( + find.descendant( + of: find.widgetWithText(SettingTile, tester.l10n.twitter), + matching: find.byType(FlatIconButton), + ), + ); + + expect(launchedUrl, equals('https://twitter.com/k_sokolovskyi')); + }); + + testWidgets('launches dribbble on dribbble button tap', (tester) async { + String? launchedUrl; + + await tester.pumpSubject((url) async { + launchedUrl = url; + }); + + await tester.tap( + find.descendant( + of: find.widgetWithText(SettingTile, tester.l10n.dribbble), + matching: find.byType(FlatIconButton), + ), + ); + + expect(launchedUrl, equals('https://dribbble.com/ira_dehtiar')); + }); + }); +} + +extension _GetInTouchSettings on WidgetTester { + Future pumpSubject(AsyncValueSetter urlLauncher) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: GetInTouchSettings(urlLauncher: urlLauncher), + ), + ); + } +} diff --git a/test/settings/widgets/language_selector_test.dart b/test/settings/widgets/language_selector_test.dart new file mode 100644 index 0000000..1acb3ab --- /dev/null +++ b/test/settings/widgets/language_selector_test.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp/settings/widgets/widgets.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:provider/provider.dart'; +import 'package:settings_controller/settings_controller.dart'; + +class _MockSettingsController extends Mock implements SettingsController {} + +void main() { + group('LanguageSelector', () { + final localeVariant = ValueVariant( + AppLocalizations.supportedLocales.toSet(), + ); + + late ValueNotifier locale; + late SettingsController settingsController; + + setUpAll(() { + registerFallbackValue(const Locale('en')); + }); + + setUp(() { + locale = ValueNotifier(null); + + settingsController = _MockSettingsController(); + when(() => settingsController.locale).thenReturn(locale); + when( + () => settingsController.setLocale(locale: any(named: 'locale')), + ).thenReturn(null); + }); + + tearDown(() { + locale.dispose(); + }); + + testWidgets('renders correctly', (WidgetTester tester) async { + await tester.pumpSubject( + controller: settingsController, + subject: const LanguageSelector(), + ); + + expect(find.byType(LanguageSelector), findsOneWidget); + expect(find.byType(SegmentedSelector), findsOneWidget); + }); + + testWidgets( + 'first locale is selected when setting is not set', + (WidgetTester tester) async { + locale.value = null; + + await tester.pumpSubject( + controller: settingsController, + subject: const LanguageSelector(), + ); + + final selector = tester.widget>( + find.byType(SegmentedSelector), + ); + expect( + selector.selected, + equals(AppLocalizations.supportedLocales.first), + ); + }, + ); + + testWidgets( + 'first locale is selected when not supported locale is set', + (WidgetTester tester) async { + locale.value = const Locale('fr'); + + await tester.pumpSubject( + controller: settingsController, + subject: const LanguageSelector(), + ); + + final selector = tester.widget>( + find.byType(SegmentedSelector), + ); + expect( + selector.selected, + equals(AppLocalizations.supportedLocales.first), + ); + }, + ); + + testWidgets( + 'context locale is selected when setting is not set', + (WidgetTester tester) async { + locale.value = null; + + final contextLocale = AppLocalizations.supportedLocales.last; + + await tester.pumpSubject( + controller: settingsController, + subject: const LanguageSelector(), + locale: contextLocale, + ); + + final selector = tester.widget>( + find.byType(SegmentedSelector), + ); + expect(selector.selected, equals(contextLocale)); + }, + ); + + testWidgets( + 'correctly sets initially selected locale from context', + (WidgetTester tester) async { + locale.value = null; + + final contextLocale = localeVariant.currentValue!; + + await tester.pumpSubject( + controller: settingsController, + subject: const LanguageSelector(), + locale: contextLocale, + ); + + final selector = tester.widget>( + find.byType(SegmentedSelector), + ); + expect(selector.selected, equals(contextLocale)); + }, + variant: localeVariant, + ); + + testWidgets( + 'correctly sets initially selected locale from setting', + (WidgetTester tester) async { + final settingLocale = localeVariant.currentValue!; + + locale.value = settingLocale; + + await tester.pumpSubject( + controller: settingsController, + subject: const LanguageSelector(), + ); + + final selector = tester.widget>( + find.byType(SegmentedSelector), + ); + expect(selector.selected, equals(settingLocale)); + }, + variant: localeVariant, + ); + + testWidgets( + 'calls SettingsController.setLocale when new locale is selected', + (WidgetTester tester) async { + final locales = localeVariant.values.toList(); + final currentLocale = localeVariant.currentValue!; + final nextIndex = (locales.indexOf(currentLocale) + 1) % locales.length; + final nextLocale = locales[nextIndex]; + + locale.value = currentLocale; + + await tester.pumpSubject( + controller: settingsController, + subject: const LanguageSelector(), + ); + + await tester.tap(find.text(nextLocale.toString().toUpperCase())); + + verify( + () => settingsController.setLocale(locale: nextLocale), + ).called(1); + }, + variant: localeVariant, + ); + }); +} + +extension _DarkModeSwitcher on WidgetTester { + Future pumpSubject({ + required SettingsController controller, + required Widget subject, + Locale? locale, + }) { + return pumpWidget( + Provider.value( + value: controller, + child: MaterialApp( + locale: locale, + supportedLocales: { + ...AppLocalizations.supportedLocales, + if (locale != null) locale, + }.toList(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + theme: GyverLampTheme.lightThemeData, + home: Scaffold( + body: Center(child: subject), + ), + ), + ), + ); + } +} diff --git a/test/settings/widgets/other_settings_test.dart b/test/settings/widgets/other_settings_test.dart new file mode 100644 index 0000000..c7b0805 --- /dev/null +++ b/test/settings/widgets/other_settings_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/l10n/l10n.dart'; +import 'package:gyver_lamp/settings/settings.dart'; +import 'package:gyver_lamp_icons/gyver_lamp_icons.dart'; +import 'package:gyver_lamp_ui/gyver_lamp_ui.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('OtherSettings', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpSubject((_) async {}); + + expect(find.byType(SettingTileGroup), findsOneWidget); + expect(find.text(tester.l10n.otherStuff), findsOneWidget); + + expect(find.byType(SettingTile), findsNWidgets(4)); + expect(find.byType(FlatIconButton), findsNWidgets(4)); + expect(find.byIcon(GyverLampIcons.arrow_outward), findsNWidgets(4)); + + expect(find.byIcon(GyverLampIcons.github), findsOneWidget); + expect(find.text(tester.l10n.lampProject), findsOneWidget); + + expect(find.byIcon(GyverLampIcons.group), findsOneWidget); + expect(find.text(tester.l10n.credits), findsOneWidget); + + expect(find.byIcon(GyverLampIcons.policy), findsOneWidget); + expect(find.text(tester.l10n.privacyPolicy), findsOneWidget); + + expect(find.byIcon(GyverLampIcons.align_left), findsOneWidget); + expect(find.text(tester.l10n.termsOfUse), findsOneWidget); + }); + + testWidgets( + 'launches lamp project github on github button tap', + (tester) async { + String? launchedUrl; + + await tester.pumpSubject((url) async { + launchedUrl = url; + }); + + await tester.tap( + find.descendant( + of: find.widgetWithText(SettingTile, tester.l10n.lampProject), + matching: find.byType(FlatIconButton), + ), + ); + + expect(launchedUrl, equals('https://github.com/AlexGyver/GyverLamp')); + }, + ); + }); +} + +extension _OtherSettings on WidgetTester { + Future pumpSubject(AsyncValueSetter urlLauncher) { + return pumpWidget( + MaterialApp( + theme: GyverLampTheme.lightThemeData, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: OtherSettings(urlLauncher: urlLauncher), + ), + ); + } +} diff --git a/test/splash/view/splash_page_test.dart b/test/splash/view/splash_page_test.dart new file mode 100644 index 0000000..a685185 --- /dev/null +++ b/test/splash/view/splash_page_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gyver_lamp/splash/splash.dart'; +import 'package:rive/rive.dart'; + +void main() { + group('SplashPage', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpWidget(const SplashPage()); + + expect(find.byType(SplashPage), findsOneWidget); + expect(find.byType(RiveAnimation), findsOneWidget); + }); + + testWidgets('fades in correctly', (tester) async { + await tester.pumpWidget(const SplashPage()); + + await tester.pump(); + + expect(tester.hasRunningAnimations, isTrue); + + await tester.pump(SplashPage.kFadeDuration); + + final fade = tester.widget( + find.byType(FadeTransition), + ); + + expect(fade.opacity.value, equals(1)); + }); + + testWidgets('runs rive animation after fade', (tester) async { + await tester.pumpWidget(const SplashPage()); + + await tester.pump(); + + final splash = tester.state( + find.byType(SplashPage), + ); + + splash.debugAnimationController!.value = 1; + + await tester.pump(); + + expect(tester.hasRunningAnimations, isTrue); + + await tester.pump(SplashPage.kAnimationDuration); + + await tester.pump(); + + expect(tester.hasRunningAnimations, isFalse); + }); + }); +}