From 8976638c3cee226373e27c6824ceb7d2bf461800 Mon Sep 17 00:00:00 2001 From: David Gomes <10091092+davidgomesdev@users.noreply.github.com> Date: Sun, 14 Jan 2024 16:26:28 +0000 Subject: [PATCH] feat(app): add candle effect --- app/lib/bloc/effects/candle_bloc.dart | 36 +++++++++ app/lib/main.dart | 31 ++++++-- app/lib/model/led_effects.dart | 75 ++++++++++++++++++- app/lib/model/led_effects.g.dart | 28 ++++++- .../effect_settings/candle_settings.dart | 74 ++++++++++++++++++ .../common/labeled_slider.dart | 72 ++++++++++++++++++ app/lib/widgets/effect_widget.dart | 7 +- app/pubspec.lock | 8 ++ app/pubspec.yaml | 1 + 9 files changed, 320 insertions(+), 12 deletions(-) create mode 100644 app/lib/bloc/effects/candle_bloc.dart create mode 100644 app/lib/widgets/effect_settings/candle_settings.dart diff --git a/app/lib/bloc/effects/candle_bloc.dart b/app/lib/bloc/effects/candle_bloc.dart new file mode 100644 index 00000000..14f0b3ef --- /dev/null +++ b/app/lib/bloc/effects/candle_bloc.dart @@ -0,0 +1,36 @@ +import 'package:rusty_controller/bloc/specific_effect_bloc.dart'; +import 'package:rusty_controller/model/led_effects.dart'; + +class CandleBloc + extends SpecificEffectBloc { + CandleBloc(super.effect) { + on((event, emit) => emit(event.toEffect(state))); + } +} + +class CandleEffectEvent { + double? hue; + double? saturation; + double? minValue; + double? maxValue; + double? variability; + int? interval; + + CandleEffectEvent( + {this.hue, + this.saturation, + this.minValue, + this.maxValue, + this.variability, + this.interval}); + + CandleLedEffect toEffect(CandleLedEffect currentEffect) { + return CandleLedEffect( + hue: hue ?? currentEffect.hue, + saturation: saturation ?? currentEffect.saturation, + minValue: minValue ?? currentEffect.minValue, + maxValue: maxValue ?? currentEffect.maxValue, + variability: variability ?? currentEffect.variability, + interval: interval ?? currentEffect.interval); + } +} diff --git a/app/lib/main.dart b/app/lib/main.dart index c51b26af..722b5d6e 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -5,6 +5,7 @@ import 'package:logger/logger.dart'; import 'package:rusty_controller/bloc/discovery_bloc.dart'; import 'package:rusty_controller/bloc/effect_bloc.dart'; import 'package:rusty_controller/bloc/effects/breathing_bloc.dart'; +import 'package:rusty_controller/bloc/effects/candle_bloc.dart'; import 'package:rusty_controller/bloc/effects/rainbow_bloc.dart'; import 'package:rusty_controller/bloc/effects/static_bloc.dart'; import 'package:rusty_controller/extensions/color_extensions.dart'; @@ -22,12 +23,24 @@ final defaultEffects = { EffectType.off: OffLedEffect(), EffectType.static: StaticLedEffect(color: Colors.black.toHSV()), EffectType.breathing: BreathingLedEffect( - color: Colors.red.toHSV().withValue(0.0), - timeToPeak: maxBreathingTime, - peak: 1.0, - breatheFromOff: true), + color: Colors.red.toHSV().withValue(0.0), + timeToPeak: maxBreathingTime, + peak: 1.0, + breatheFromOff: true, + ), + EffectType.candle: CandleLedEffect( + hue: 0, + saturation: 1.0, + minValue: 0.5, + maxValue: 0.8, + variability: 1.0, + interval: 100, + ), EffectType.rainbow: RainbowLedEffect( - saturation: 1.0, value: 0.5, timeToComplete: maxRainbowTime), + saturation: 1.0, + value: 0.5, + timeToComplete: maxRainbowTime, + ), }; void main() { @@ -78,6 +91,14 @@ void setupDependencies() { return BreathingBloc(savedBreathing); }, ); + serviceLocator.registerSingletonAsync( + () async { + final savedCandle = await storeService.get( + defaultValue: defaultEffects[EffectType.candle] as CandleLedEffect); + + return CandleBloc(savedCandle); + }, + ); serviceLocator.registerSingletonAsync( () async { final savedRainbow = await storeService.get( diff --git a/app/lib/model/led_effects.dart b/app/lib/model/led_effects.dart index c58e6263..505a4ac5 100644 --- a/app/lib/model/led_effects.dart +++ b/app/lib/model/led_effects.dart @@ -1,3 +1,4 @@ +import 'package:equatable/equatable.dart'; import 'package:flutter/painting.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:rusty_controller/model/util/json_converters.dart'; @@ -5,7 +6,7 @@ import 'package:rusty_controller/service/store_service.dart'; part 'led_effects.g.dart'; -abstract class LedEffect { +abstract class LedEffect extends Equatable { EffectType get type; String get name => type.name; @@ -17,7 +18,7 @@ abstract class LedEffect { Map get graphqlVariables; } -enum EffectType { none, off, static, breathing, rainbow } +enum EffectType { none, off, static, breathing, candle, rainbow } // A placeholder/null-object pattern for when there's no effect selected, // while avoiding null-checks @@ -33,6 +34,9 @@ class NoLedEffect extends LedEffect { @override Map get graphqlVariables => {}; + + @override + List get props => []; } class OffLedEffect extends LedEffect { @@ -51,6 +55,9 @@ class OffLedEffect extends LedEffect { @override Map get graphqlVariables => {}; + + @override + List get props => []; } @JsonSerializable() @@ -89,6 +96,9 @@ class StaticLedEffect extends LedEffect implements StorableObject { @override Map toJson() => _$StaticLedEffectToJson(this); + + @override + List get props => [color]; } @JsonSerializable() @@ -136,6 +146,64 @@ class BreathingLedEffect extends LedEffect implements StorableObject { @override Map toJson() => _$BreathingLedEffectToJson(this); + + @override + List get props => [color, timeToPeak, peak, breatheFromOff]; +} + +@JsonSerializable() +@HSVColorJsonConverter() +class CandleLedEffect extends LedEffect implements StorableObject { + @override + EffectType get type => EffectType.candle; + + final double hue; + final double saturation; + final double minValue; + final double maxValue; + final double variability; + final int interval; + + CandleLedEffect( + {required this.hue, + required this.saturation, + required this.minValue, + required this.maxValue, + required this.variability, + required this.interval}); + + @override + String get graphqlMutation => """ + mutation SetLedCandle(\$input: CandleLedEffectInput!) { + setLedCandle(input: \$input) + } + """; + + @override + String get graphqlMutationName => "setLedCandle"; + + @override + Map get graphqlVariables => { + "hue": hue.round(), + "saturation": saturation, + "minValue": minValue, + "maxValue": maxValue, + "variability": variability, + "interval": interval + }; + + @override + String get storeName => "candle"; + + @override + CandleLedEffect fromJson(Map json) => + _$CandleLedEffectFromJson(json); + + @override + Map toJson() => _$CandleLedEffectToJson(this); + + @override + List get props => [hue, saturation, minValue, maxValue, variability, interval]; } @JsonSerializable() @@ -178,4 +246,7 @@ class RainbowLedEffect extends LedEffect implements StorableObject { @override Map toJson() => _$RainbowLedEffectToJson(this); + + @override + List get props => [saturation, value, timeToComplete]; } diff --git a/app/lib/model/led_effects.g.dart b/app/lib/model/led_effects.g.dart index e191761f..bef365f5 100644 --- a/app/lib/model/led_effects.g.dart +++ b/app/lib/model/led_effects.g.dart @@ -21,7 +21,7 @@ BreathingLedEffect _$BreathingLedEffectFromJson(Map json) => BreathingLedEffect( color: const HSVColorJsonConverter() .fromJson(json['color'] as Map), - timeToPeak: json['step'] as int, + timeToPeak: json['timeToPeak'] as int, peak: (json['peak'] as num).toDouble(), breatheFromOff: json['breatheFromOff'] as bool, ); @@ -29,21 +29,41 @@ BreathingLedEffect _$BreathingLedEffectFromJson(Map json) => Map _$BreathingLedEffectToJson(BreathingLedEffect instance) => { 'color': const HSVColorJsonConverter().toJson(instance.color), - 'step': instance.timeToPeak, + 'timeToPeak': instance.timeToPeak, 'peak': instance.peak, 'breatheFromOff': instance.breatheFromOff, }; +CandleLedEffect _$CandleLedEffectFromJson(Map json) => + CandleLedEffect( + hue: (json['hue'] as num).toDouble(), + saturation: (json['saturation'] as num).toDouble(), + minValue: (json['minValue'] as num).toDouble(), + maxValue: (json['maxValue'] as num).toDouble(), + variability: (json['variability'] as num).toDouble(), + interval: json['interval'] as int, + ); + +Map _$CandleLedEffectToJson(CandleLedEffect instance) => + { + 'hue': instance.hue, + 'saturation': instance.saturation, + 'minValue': instance.minValue, + 'maxValue': instance.maxValue, + 'variability': instance.variability, + 'interval': instance.interval, + }; + RainbowLedEffect _$RainbowLedEffectFromJson(Map json) => RainbowLedEffect( saturation: (json['saturation'] as num).toDouble(), value: (json['value'] as num).toDouble(), - timeToComplete: (json['step'] as num).toDouble(), + timeToComplete: (json['timeToComplete'] as num).toDouble(), ); Map _$RainbowLedEffectToJson(RainbowLedEffect instance) => { 'saturation': instance.saturation, 'value': instance.value, - 'step': instance.timeToComplete, + 'timeToComplete': instance.timeToComplete, }; diff --git a/app/lib/widgets/effect_settings/candle_settings.dart b/app/lib/widgets/effect_settings/candle_settings.dart new file mode 100644 index 00000000..673c7c0d --- /dev/null +++ b/app/lib/widgets/effect_settings/candle_settings.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'package:rusty_controller/bloc/effects/candle_bloc.dart'; +import 'package:rusty_controller/main.dart'; +import 'package:rusty_controller/model/led_effects.dart'; +import 'package:rusty_controller/widgets/effect_settings/common/labeled_slider.dart'; + +class CandleSettings extends StatelessWidget { + final initialColor = const HSVColor.fromAHSV(1.0, 0.0, 1.0, 1.0); + + const CandleSettings({super.key}); + + @override + Widget build(BuildContext context) { + final bloc = serviceLocator.get(); + return BlocBuilder( + bloc: bloc, + builder: (ctx, effect) { + final color = initialColor.withHue(effect.hue); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Padding( + padding: EdgeInsets.only(bottom: 48.0), + child: Stack( + alignment: Alignment.center, + children: [ + ColorIndicator(color, width: 60.0, height: 60.0), + SizedBox( + width: 200, + height: 200, + child: ColorPickerHueRing( + color, + displayThumbColor: false, + strokeWidth: 30.0, + (color) { + bloc.add(CandleEffectEvent(hue: color.hue)); + }, + ), + ) + ], + ), + ), + ), + Column( + children: [ + LabeledRangeSlider( + onChanged: (min, max) { + bloc.add(CandleEffectEvent(minValue: min, maxValue: max)); + }, + label: "Brightness range", + start: effect.minValue, + end: effect.maxValue, + ), + LabeledSlider( + onChanged: (interval) { + bloc.add(CandleEffectEvent(interval: interval.toInt())); + }, + label: "Interval", + value: effect.interval.toDouble(), + min: 100, + max: 800, + ), + ], + ), + ], + ); + }, + ); + } +} diff --git a/app/lib/widgets/effect_settings/common/labeled_slider.dart b/app/lib/widgets/effect_settings/common/labeled_slider.dart index 5fe38bbe..21271554 100644 --- a/app/lib/widgets/effect_settings/common/labeled_slider.dart +++ b/app/lib/widgets/effect_settings/common/labeled_slider.dart @@ -68,3 +68,75 @@ class LabeledSlider extends StatelessWidget { ); } } + +class LabeledLogDoubleSlider extends StatelessWidget { + final double value, end, min, max; + final double scale; + final String label; + final void Function(double, double) onChanged; + + LabeledLogDoubleSlider( + {super.key, + required this.onChanged, + required this.label, + required this.value, + required this.end, + this.min = 1.0, + this.max = 10.0}) + : scale = log(max) - log(min); + + @override + Widget build(BuildContext context) { + return LabeledRangeSlider( + onChanged: (double position, double secondaryPosition) => + onChanged(getLogValue(position), getLogValue(secondaryPosition)), + label: label, + start: getPosition(value), + end: getPosition(end), + ); + } + + double getLogValue(double slidePosition) { + return math.exp(scale * slidePosition + log(min)); + } + + double getPosition(double position) { + return (log(position) - log(min)) / scale; + } + + static double log(double num) { + if (num == 0.0) return 0.0; + + return math.log(num); + } +} + +class LabeledRangeSlider extends StatelessWidget { + final double start, end, min, max; + final String label; + final void Function(double, double) onChanged; + + const LabeledRangeSlider( + {super.key, + required this.onChanged, + required this.label, + required this.start, + required this.end, + this.max = 1.0, + this.min = 0.0}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text(label), + RangeSlider( + values: RangeValues(start, end), + max: max, + min: min, + onChanged: (values) => onChanged(values.start, values.end), + ), + ], + ); + } +} diff --git a/app/lib/widgets/effect_widget.dart b/app/lib/widgets/effect_widget.dart index ea9d4f02..0b786932 100644 --- a/app/lib/widgets/effect_widget.dart +++ b/app/lib/widgets/effect_widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:rusty_controller/model/led_effects.dart'; import 'package:rusty_controller/widgets/effect_settings/breathing_settings.dart'; +import 'package:rusty_controller/widgets/effect_settings/candle_settings.dart'; import 'package:rusty_controller/widgets/effect_settings/off_settings.dart'; import 'package:rusty_controller/widgets/effect_settings/rainbow_settings.dart'; import 'package:rusty_controller/widgets/effect_settings/static_settings.dart'; @@ -18,12 +19,16 @@ class EffectWidget extends StatelessWidget { return const StaticSettings(); } else if (effect is BreathingLedEffect) { return const BreathingSettings(); + } else if (effect is CandleLedEffect) { + return const CandleSettings(); } else if (effect is RainbowLedEffect) { return const RainbowSettings(); } else if (effect is OffLedEffect) { return const OffEffectWidget(); - } else { + } else if (effect is NoLedEffect) { return Container(); + } else { + throw ArgumentError("No widget for effect $effect"); } } } diff --git a/app/pubspec.lock b/app/pubspec.lock index 7c7f0376..924fba36 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -209,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" fake_async: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 06bca58d..830c492a 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: path_provider: ^2.0.11 json_annotation: ^4.8.1 get: ^4.6.5 + equatable: ^2.0.5 dev_dependencies: flutter_test: