diff --git a/lib/ui/pages/workspace_page/components/bottom_bar/bottom_nav_bar.dart b/lib/ui/pages/workspace_page/components/bottom_bar/bottom_nav_bar.dart index 6f23f496..2a437d30 100644 --- a/lib/ui/pages/workspace_page/components/bottom_bar/bottom_nav_bar.dart +++ b/lib/ui/pages/workspace_page/components/bottom_bar/bottom_nav_bar.dart @@ -113,24 +113,20 @@ void _handleToolOptionsVisibility(WidgetRef ref) { } void _showColorPicker(BuildContext context, WidgetRef ref) { - showModalBottomSheet( + final Color initialColor = ref.read(paintProvider).color; + + showDialog( context: context, - isScrollControlled: true, - builder: (BuildContext dialogContext) => Container( - height: MediaQuery.of(dialogContext).size.height * 0.7, - alignment: Alignment.center, - decoration: BoxDecoration( - color: PaintroidTheme.of(dialogContext).onSurfaceColor, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16.0), - topRight: Radius.circular(16.0), - )), - child: ColorPicker( - currentColor: ref.watch(paintProvider).color, - onColorChanged: (newColor) { - ref.watch(paintProvider.notifier).updateColor(newColor); - }, - ), - ), + builder: (BuildContext dialogContext) { + return Dialog( + clipBehavior: Clip.antiAlias, + child: ColorPicker( + currentColor: initialColor, + onColorChanged: (newColor) { + ref.read(paintProvider.notifier).updateColor(newColor); + }, + ), + ); + }, ); } diff --git a/packages/colorpicker/lib/colorpicker.dart b/packages/colorpicker/lib/colorpicker.dart index 88950ae5..95c502ad 100644 --- a/packages/colorpicker/lib/colorpicker.dart +++ b/packages/colorpicker/lib/colorpicker.dart @@ -5,3 +5,4 @@ export 'src/constants/colors.dart'; export 'src/components/color_comparison.dart'; export 'src/components/opacity_slider.dart'; export 'src/components/slider_indicator_shape.dart'; +export 'src/components/color_wheel.dart'; diff --git a/packages/colorpicker/lib/src/colorpicker.dart b/packages/colorpicker/lib/src/colorpicker.dart index 41e952b3..605d6324 100644 --- a/packages/colorpicker/lib/src/colorpicker.dart +++ b/packages/colorpicker/lib/src/colorpicker.dart @@ -1,13 +1,17 @@ -import 'package:colorpicker/src/components/checkerboard_square.dart'; +import 'package:flutter/material.dart'; + import 'package:colorpicker/src/components/color_comparison.dart'; -import 'package:colorpicker/src/components/color_square.dart'; import 'package:colorpicker/src/components/opacity_slider.dart'; -import 'package:colorpicker/src/constants/colors.dart'; +import 'package:colorpicker/src/components/recent_colors_section_widget.dart'; +import 'package:colorpicker/src/components/custom_tab_widget.dart'; +import 'package:colorpicker/src/components/picker_content_widget.dart'; +import 'package:colorpicker/src/enums/main_picker_mode_type.dart'; import 'package:colorpicker/src/state/color_picker_state_provider.dart'; -import 'package:flutter/material.dart'; +import 'package:colorpicker/src/state/recent_color_state_provider.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:colorpicker/src/constants/colorpicker_colors.dart'; -class ColorPicker extends ConsumerWidget { +class ColorPicker extends ConsumerStatefulWidget { const ColorPicker({ super.key, required this.currentColor, @@ -17,77 +21,186 @@ class ColorPicker extends ConsumerWidget { final Color currentColor; final void Function(Color) onColorChanged; - final colors = DisplayColors.colors; + @override + ConsumerState createState() => _ColorPickerState(); +} + +class _ColorPickerState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + MainPickerMode _mainPickerMode = MainPickerMode.grid; + + final double _selectedIndicatorHeight = 5.0; + final double _unselectedIndicatorHeight = 2.0; @override - Widget build(BuildContext context, WidgetRef ref) { - final colorPickerStateData = ref.watch(colorPickerStateProvider); - return Container( - margin: const EdgeInsets.all(26.0), - alignment: Alignment.center, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.0), - ), - child: SingleChildScrollView( + void initState() { + super.initState(); + _tabController = TabController( + length: 3, + vsync: this, + initialIndex: MainPickerMode.values.indexOf(_mainPickerMode)); + _tabController.addListener(_handleTabChange); + + WidgetsBinding.instance.addPostFrameCallback((_) { + ref + .read(colorPickerStateProvider.notifier) + .updateColor(widget.currentColor.withAlpha(255)); + ref + .read(colorPickerStateProvider.notifier) + .updateOpacity(widget.currentColor.a); + }); + } + + @override + void dispose() { + _tabController.removeListener(_handleTabChange); + _tabController.dispose(); + super.dispose(); + } + + void _handleTabChange() { + if (mounted && + _mainPickerMode != MainPickerMode.values[_tabController.index]) { + setState(() { + _mainPickerMode = MainPickerMode.values[_tabController.index]; + }); + } + } + + void _handleColorChange(Color color) { + ref + .read(colorPickerStateProvider.notifier) + .updateColor(color.withAlpha(255)); + } + + void _handleColorAndOpacityChange(Color color) { + ref + .read(colorPickerStateProvider.notifier) + .updateColor(color.withAlpha(255)); + ref.read(colorPickerStateProvider.notifier).updateOpacity(color.a); + } + + @override + Widget build(BuildContext context) { + final colorPickerState = ref.watch(colorPickerStateProvider); + final opacity = colorPickerState.currentOpacity; + final baseColor = colorPickerState.currentColor ?? widget.currentColor; + final displayColor = + baseColor.withAlpha((opacity.clamp(0.0, 1.0) * 255).round()); + + final solidColorForPickers = + (colorPickerState.currentColor ?? widget.currentColor.withAlpha(255)) + .withAlpha(255); + + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.circular(16.0), + ), child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.min, children: [ - ColorComparison( - currentColor: currentColor, - newColor: colorPickerStateData.currentColor != null - ? colorPickerStateData.currentColor!.withValues( - alpha: colorPickerStateData.currentOpacity, - ) - : currentColor, - ), - const SizedBox(height: 10.0), - GridView.count( - childAspectRatio: 1.4, - crossAxisCount: 4, - crossAxisSpacing: 2.0, - mainAxisSpacing: 2.0, - shrinkWrap: true, - children: List.generate( - colors.length + 1, - (index) { - if (index == colors.length) { - return const CheckerboardSquare(); - } else { - return ColorSquare(color: colors[index]); - } - }, + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ColorComparison( + currentColor: widget.currentColor, + newColor: displayColor, + ), + RecentColorsSectionWidget( + onColorSelected: _handleColorAndOpacityChange), + const SizedBox(height: 20.0), + SizedBox( + height: 48, + child: TabBar( + controller: _tabController, + labelColor: colorScheme.primary, + dividerHeight: 0, + unselectedLabelColor: colorScheme.onSurface + .withAlpha((255 * 0.7).toInt()), + indicator: const BoxDecoration(), + tabs: [ + CustomTabWidget( + iconWidget: const Icon(Icons.grid_on), + index: 0, + tabController: _tabController, + selectedIndicatorHeight: _selectedIndicatorHeight, + unselectedIndicatorHeight: + _unselectedIndicatorHeight, + ), + CustomTabWidget( + iconWidget: const Icon(Icons.circle), + index: 1, + tabController: _tabController, + selectedIndicatorHeight: _selectedIndicatorHeight, + unselectedIndicatorHeight: + _unselectedIndicatorHeight, + ), + CustomTabWidget( + iconWidget: const Icon(Icons.tune), + index: 2, + tabController: _tabController, + selectedIndicatorHeight: _selectedIndicatorHeight, + unselectedIndicatorHeight: + _unselectedIndicatorHeight, + ), + ], + ), + ), + const SizedBox(height: 15.0), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: PickerContentWidget( + mainPickerMode: _mainPickerMode, + colorForPickers: solidColorForPickers, + onColorChanged: _handleColorChange, + onColorAndOpacityChanged: _handleColorAndOpacityChange, + ), + ), + if (_mainPickerMode != MainPickerMode.sliders) ...[ + const SizedBox(height: 20.0), + OpacitySlider(gradientColor: solidColorForPickers), + ], + const SizedBox(height: 20.0), + ], + ), ), ), - const SizedBox(height: 20.0), - OpacitySlider( - gradientColor: colorPickerStateData.currentColor != null - ? colorPickerStateData.currentColor! - : currentColor, - ), - const SizedBox(height: 20.0), Row( + mainAxisAlignment: MainAxisAlignment.end, children: [ - const Spacer(), TextButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text('CANCEL'), + onPressed: () => Navigator.pop(context), + child: const Text('CANCEL', + style: TextStyle( + color: ColorPickerColors.oceanBlue, + fontWeight: FontWeight.w500)), ), - const SizedBox(width: 10.0), + const SizedBox(width: 15.0), TextButton( onPressed: () { - if (colorPickerStateData.currentColor != null) { - onColorChanged(colorPickerStateData.currentColor! - .withValues( - alpha: colorPickerStateData.currentOpacity)); - } + ref + .read(recentColorsProvider.notifier) + .addColor(displayColor); + widget.onColorChanged(displayColor); Navigator.pop(context); }, - child: const Text('APPLY'), + child: const Text('APPLY', + style: TextStyle( + color: ColorPickerColors.oceanBlue, + fontWeight: FontWeight.w500)), ), ], - ) + ), ], ), ), diff --git a/packages/colorpicker/lib/src/components/advanced_picker_widget.dart b/packages/colorpicker/lib/src/components/advanced_picker_widget.dart new file mode 100644 index 00000000..d50217e7 --- /dev/null +++ b/packages/colorpicker/lib/src/components/advanced_picker_widget.dart @@ -0,0 +1,90 @@ +import 'package:colorpicker/src/components/color_wheel.dart'; +import 'package:colorpicker/src/components/hue_saturation_picker.dart'; +import 'package:colorpicker/src/components/toggle_item_widget.dart'; +import 'package:colorpicker/src/constants/colorpicker_colors.dart'; +import 'package:colorpicker/src/enums/advanced_picker_mode_type.dart'; +import 'package:flutter/material.dart'; + +class AdvancedPickerWidget extends StatefulWidget { + final Color colorForPickers; + final void Function(Color) onColorChanged; + final String text1; + final String text2; + + const AdvancedPickerWidget({ + super.key, + required this.colorForPickers, + required this.onColorChanged, + required this.text1, + required this.text2, + }); + + @override + State createState() => + _AdvancedPickerWidgetState(); +} + +class _AdvancedPickerWidgetState extends State { + AdvancedPickerMode _advancedPickerMode = AdvancedPickerMode.picker; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Column( + key: const ValueKey('advanced_picker'), + mainAxisSize: MainAxisSize.min, + children: [ + ToggleButtons( + isSelected: [ + _advancedPickerMode == AdvancedPickerMode.picker, + _advancedPickerMode == AdvancedPickerMode.wheel, + ], + onPressed: (int index) { + setState(() { + _advancedPickerMode = index == 0 + ? AdvancedPickerMode.picker + : AdvancedPickerMode.wheel; + }); + }, + borderRadius: BorderRadius.circular(18.0), + borderWidth: 1.5, + borderColor: colorScheme.primary, + selectedBorderColor: colorScheme.primary, + fillColor: colorScheme.surface, + color: ColorPickerColors.orange, + constraints: const BoxConstraints(minHeight: 30.0, minWidth: 70.0), + children: [ + ToggleItemWidget( + text: widget.text1, + currentMode: _advancedPickerMode, + buttonMode: AdvancedPickerMode.picker), + ToggleItemWidget( + text: widget.text2, + currentMode: _advancedPickerMode, + buttonMode: AdvancedPickerMode.wheel), + ], + ), + const SizedBox(height: 20), + SizedBox( + height: 220.0, + child: Center( + child: _advancedPickerMode == AdvancedPickerMode.picker + ? HueSaturationValuePicker( + initialColor: widget.colorForPickers, + onColorChanged: widget.onColorChanged) + : ColorWheel( + pickerColor: widget.colorForPickers, + onColorChanged: widget.onColorChanged), + ), + ), + ], + ); + } +} diff --git a/packages/colorpicker/lib/src/components/color_comparison.dart b/packages/colorpicker/lib/src/components/color_comparison.dart index e7f33438..8897574b 100644 --- a/packages/colorpicker/lib/src/components/color_comparison.dart +++ b/packages/colorpicker/lib/src/components/color_comparison.dart @@ -68,7 +68,7 @@ class ColorDescription extends StatelessWidget { const SizedBox(width: 8.0), Text( description, - style: const TextStyle(color: Color.fromARGB(255, 149, 149, 149)), + style: const TextStyle(color: Colors.black), ), ], ); diff --git a/packages/colorpicker/lib/src/components/color_slider_row_widget.dart b/packages/colorpicker/lib/src/components/color_slider_row_widget.dart new file mode 100644 index 00000000..d59715ce --- /dev/null +++ b/packages/colorpicker/lib/src/components/color_slider_row_widget.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:colorpicker/src/constants/colorpicker_colors.dart'; + +class ColorSliderRowWidget extends StatelessWidget { + final String label; + final double value; + final double min; + final double max; + final ValueChanged onChanged; + final Color sliderActiveColor; + final bool isAlpha; + + const ColorSliderRowWidget({ + super.key, + required this.label, + required this.value, + required this.min, + required this.max, + required this.onChanged, + required this.sliderActiveColor, + this.isAlpha = false, + }); + + @override + Widget build(BuildContext context) { + final clampedValue = value.clamp(min, max); + final String displayValue = isAlpha && max == 255 + ? '${(clampedValue / 255 * 100).round()}%' + : clampedValue.round().toString(); + + return Row( + children: [ + SizedBox( + width: 55, + child: Text(label, + style: TextStyle( + fontSize: 14, + color: + isAlpha ? ColorPickerColors.oceanBlue : sliderActiveColor, + )), + ), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: + sliderActiveColor.withAlpha((255 * 0.7).round()), + inactiveTrackColor: + sliderActiveColor.withAlpha((255 * 0.3).round()), + thumbColor: sliderActiveColor, + overlayColor: sliderActiveColor.withAlpha(0x29), + trackHeight: 6.0, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9.0), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 14.0), + ), + child: Slider( + value: clampedValue, + min: min, + max: max, + divisions: (max - min).round(), + label: displayValue, + onChanged: onChanged, + ), + ), + ), + SizedBox( + width: 45, + child: Text(displayValue, + textAlign: TextAlign.right, + style: const TextStyle( + fontSize: 14, color: ColorPickerColors.oceanBlue)), + ), + ], + ); + } +} diff --git a/packages/colorpicker/lib/src/components/color_wheel.dart b/packages/colorpicker/lib/src/components/color_wheel.dart new file mode 100644 index 00000000..7257bd70 --- /dev/null +++ b/packages/colorpicker/lib/src/components/color_wheel.dart @@ -0,0 +1,167 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:colorpicker/src/components/color_wheel_painter.dart'; +import 'package:colorpicker/src/constants/painter_thumb_constants.dart'; + +class ColorWheel extends StatefulWidget { + final Color pickerColor; + final ValueChanged onColorChanged; + + const ColorWheel({ + super.key, + required this.pickerColor, + required this.onColorChanged, + }); + + @override + State createState() => _ColorWheelState(); +} + +class _ColorWheelState extends State { + late Offset thumbPosition; + double _currentRadius = 0.0; + + static const double _thumbDiameter = 24.0; + static const double _thumbRadius = _thumbDiameter / 2; + + @override + void initState() { + super.initState(); + thumbPosition = Offset.zero; + } + + void _initializeThumbPosition(double radius) { + if ((_currentRadius - radius).abs() < 0.01 && + thumbPosition != Offset.zero && + _currentRadius != 0.0) { + return; + } + + _currentRadius = radius; + final hsv = HSVColor.fromColor(widget.pickerColor); + final angle = hsv.hue * pi / 180; + final distance = (hsv.saturation * radius).clamp(0.0, radius); + + if (mounted) { + setState(() { + thumbPosition = Offset( + radius + distance * cos(angle), + radius + distance * sin(angle), + ); + }); + } + } + + void _updateColorFromPosition(Offset localPosition, double radius) { + final center = Offset(radius, radius); + final dx = localPosition.dx - center.dx; + final dy = localPosition.dy - center.dy; + final rawDistance = sqrt(dx * dx + dy * dy); + + final distance = rawDistance.clamp(0.0, radius); + + final angle = atan2(dy, dx); + final hue = (angle * 180 / pi + 360) % 360; + + final saturation = (distance / radius).clamp(0.0, 1.0); + + final newColor = HSVColor.fromAHSV(1.0, hue, saturation, 1.0).toColor(); + + setState(() { + if (rawDistance > radius) { + thumbPosition = Offset( + center.dx + radius * cos(angle), center.dy + radius * sin(angle)); + } else { + thumbPosition = localPosition; + } + }); + + widget.onColorChanged(newColor); + } + + @override + void didUpdateWidget(covariant ColorWheel oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.pickerColor != oldWidget.pickerColor && _currentRadius > 0) { + final hsv = HSVColor.fromColor(widget.pickerColor); + final angle = hsv.hue * pi / 180; + final distance = + (hsv.saturation * _currentRadius).clamp(0.0, _currentRadius); + final expectedPosition = Offset( + _currentRadius + distance * cos(angle), + _currentRadius + distance * sin(angle), + ); + + if ((thumbPosition - expectedPosition).distanceSquared > 1.0) { + _initializeThumbPosition(_currentRadius); + } + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final double radius = + min(constraints.maxWidth, constraints.maxHeight) * 0.5; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _initializeThumbPosition(radius); + } + }); + + if (radius <= 0) { + return const SizedBox.shrink(); + } + + return Center( + child: SizedBox( + width: radius * 2, + height: radius * 2, + child: GestureDetector( + onTapDown: (details) => + _updateColorFromPosition(details.localPosition, radius), + onPanStart: (details) => + _updateColorFromPosition(details.localPosition, radius), + onPanUpdate: (details) => + _updateColorFromPosition(details.localPosition, radius), + child: Stack( + clipBehavior: Clip.none, + children: [ + CustomPaint( + size: Size(radius * 2, radius * 2), + painter: ColorWheelPainter(radius: radius), + ), + Positioned( + left: thumbPosition.dx - _thumbRadius, + top: thumbPosition.dy - _thumbRadius, + child: Container( + width: _thumbDiameter, + height: _thumbDiameter, + decoration: BoxDecoration( + color: Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: kColorWheelThumbStrokeColor, + width: kColorWheelThumbStrokeWidth, + ), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/packages/colorpicker/lib/src/components/color_wheel_painter.dart b/packages/colorpicker/lib/src/components/color_wheel_painter.dart new file mode 100644 index 00000000..3f6a9d8a --- /dev/null +++ b/packages/colorpicker/lib/src/components/color_wheel_painter.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:colorpicker/src/constants/color_picker_constants.dart'; + +class ColorWheelPainter extends CustomPainter { + final double radius; + + ColorWheelPainter({required this.radius}); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(radius, radius); + final rect = Rect.fromCircle(center: center, radius: radius); + + final huePaint = Paint() + ..shader = hueSweepGradient.createShader(rect) + ..style = PaintingStyle.fill; + + canvas.drawCircle(center, radius, huePaint); + + final saturationGradient = RadialGradient( + colors: [ + Colors.white, + Colors.white.withAlpha(0), + ], + stops: const [0.0, 1.0], + ); + + final saturationPaint = Paint() + ..shader = saturationGradient.createShader(rect) + ..style = PaintingStyle.fill; + + canvas.drawCircle(center, radius, saturationPaint); + } + + @override + bool shouldRepaint(ColorWheelPainter oldDelegate) { + return oldDelegate.radius != radius; + } +} diff --git a/packages/colorpicker/lib/src/components/custom_tab_widget.dart b/packages/colorpicker/lib/src/components/custom_tab_widget.dart new file mode 100644 index 00000000..04b0828a --- /dev/null +++ b/packages/colorpicker/lib/src/components/custom_tab_widget.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:colorpicker/src/constants/color_picker_constants.dart'; + +class CustomTabWidget extends StatelessWidget { + final Icon iconWidget; + final int index; + final TabController tabController; + final double selectedIndicatorHeight; + final double unselectedIndicatorHeight; + + const CustomTabWidget({ + super.key, + required this.iconWidget, + required this.index, + required this.tabController, + required this.selectedIndicatorHeight, + required this.unselectedIndicatorHeight, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return AnimatedBuilder( + animation: tabController, + builder: (BuildContext context, Widget? child) { + final bool isSelected = tabController.index == index; + return GestureDetector( + onTap: () { + if (tabController.index != index) { + tabController.animateTo(index); + } + }, + child: Container( + height: 48, + padding: const EdgeInsets.only(bottom: 2.0), + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Center( + child: ShaderMask( + shaderCallback: (Rect bounds) { + return hueSweepGradient.createShader(bounds); + }, + blendMode: BlendMode.srcIn, + child: IconTheme( + data: const IconThemeData( + color: Colors.white, + ), + child: iconWidget, + ), + )), + ), + Container( + height: isSelected + ? selectedIndicatorHeight + : unselectedIndicatorHeight, + width: double.infinity, + decoration: BoxDecoration( + color: + isSelected ? colorScheme.primary : theme.dividerColor, + borderRadius: BorderRadius.circular(8.0), + ), + ), + ], + ), + ), + ); + }); + } +} diff --git a/packages/colorpicker/lib/src/components/grid_picker_widget.dart b/packages/colorpicker/lib/src/components/grid_picker_widget.dart new file mode 100644 index 00000000..ac637d0c --- /dev/null +++ b/packages/colorpicker/lib/src/components/grid_picker_widget.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:colorpicker/src/constants/colors.dart'; +import 'package:colorpicker/src/components/checkerboard_square.dart'; + +class GridPickerWidget extends StatelessWidget { + final void Function(Color) onColorSelected; + + const GridPickerWidget({super.key, required this.onColorSelected}); + + @override + Widget build(BuildContext context) { + const List colors = DisplayColors.colors; + final theme = Theme.of(context); + + return GridView.builder( + key: const ValueKey('grid_picker'), + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: colors.length + 1, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 6.0, + mainAxisSpacing: 6.0, + childAspectRatio: 1.0, + ), + itemBuilder: (context, index) { + if (index == colors.length) { + return GestureDetector( + onTap: () => onColorSelected(Colors.transparent), + child: const CheckerboardSquare(), + ); + } + final color = colors[index]; + return GestureDetector( + onTap: () => onColorSelected(color), + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4.0), + border: Border.all(color: theme.dividerColor, width: 1.0), + ), + ), + ); + }, + ); + } +} diff --git a/packages/colorpicker/lib/src/components/hex_input_row_widget.dart b/packages/colorpicker/lib/src/components/hex_input_row_widget.dart new file mode 100644 index 00000000..b52b0566 --- /dev/null +++ b/packages/colorpicker/lib/src/components/hex_input_row_widget.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:colorpicker/src/utils/upper_case_text_formatter.dart'; +import 'package:colorpicker/src/utils/hex_input_formatter.dart'; + +class HexInputRowWidget extends StatelessWidget { + const HexInputRowWidget({ + super.key, + required this.hexController, + required this.hexFocusNode, + required this.onSubmitted, + required this.onEditingComplete, + }); + + final TextEditingController hexController; + final FocusNode hexFocusNode; + final ValueChanged onSubmitted; + final VoidCallback onEditingComplete; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const SizedBox( + width: 55, + child: Text('HEX', style: TextStyle(fontSize: 14)), + ), + Expanded( + child: TextField( + controller: hexController, + focusNode: hexFocusNode, + maxLength: 9, + style: const TextStyle(fontSize: 14), + decoration: const InputDecoration( + isDense: true, + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(), + ), + counterText: '', + ), + inputFormatters: [ + UpperCaseTextFormatter(), + FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F#]')), + HexInputFormatter(), + ], + onSubmitted: onSubmitted, + onEditingComplete: onEditingComplete, + ), + ), + const SizedBox(width: 45), + ], + ), + ); + } +} diff --git a/packages/colorpicker/lib/src/components/hsv_rgb_sliders_picker_widget.dart b/packages/colorpicker/lib/src/components/hsv_rgb_sliders_picker_widget.dart new file mode 100644 index 00000000..a67b0696 --- /dev/null +++ b/packages/colorpicker/lib/src/components/hsv_rgb_sliders_picker_widget.dart @@ -0,0 +1,83 @@ +import 'package:colorpicker/src/components/hsv_slider_group.dart'; +import 'package:colorpicker/src/components/rgb_slider_group.dart'; +import 'package:colorpicker/src/components/toggle_item_widget.dart'; +import 'package:colorpicker/src/constants/colorpicker_colors.dart'; +import 'package:colorpicker/src/enums/slider_picker_mode_type.dart'; +import 'package:flutter/material.dart'; + +class HsvRgbSlidersPickerWidget extends StatefulWidget { + final Color initialColor; + final void Function(Color) onColorChanged; + + const HsvRgbSlidersPickerWidget({ + super.key, + required this.initialColor, + required this.onColorChanged, + }); + + @override + State createState() => + _HsvRgbSlidersPickerWidgetState(); +} + +class _HsvRgbSlidersPickerWidgetState extends State { + SliderPickerMode _sliderTypeMode = SliderPickerMode.hsv; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Column( + key: const ValueKey('slider_picker'), + mainAxisSize: MainAxisSize.min, + children: [ + ToggleButtons( + isSelected: [ + _sliderTypeMode == SliderPickerMode.hsv, + _sliderTypeMode == SliderPickerMode.rgb, + ], + onPressed: (int index) { + setState(() { + _sliderTypeMode = + index == 0 ? SliderPickerMode.hsv : SliderPickerMode.rgb; + }); + }, + borderRadius: BorderRadius.circular(18.0), + borderWidth: 1.5, + borderColor: colorScheme.primary, + selectedBorderColor: colorScheme.primary, + fillColor: colorScheme.surface, + color: ColorPickerColors.orange, + constraints: const BoxConstraints(minHeight: 30.0, minWidth: 70.0), + children: [ + ToggleItemWidget( + text: 'HSV', + currentMode: _sliderTypeMode, + buttonMode: SliderPickerMode.hsv), + ToggleItemWidget( + text: 'RGB', + currentMode: _sliderTypeMode, + buttonMode: SliderPickerMode.rgb), + ], + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: _sliderTypeMode == SliderPickerMode.hsv + ? HsvSliderGroup( + initialColor: widget.initialColor, + onColorChanged: widget.onColorChanged) + : RgbSliderGroup( + initialColor: widget.initialColor, + onColorChanged: widget.onColorChanged), + ), + ], + ); + } +} diff --git a/packages/colorpicker/lib/src/components/hsv_slider_group.dart b/packages/colorpicker/lib/src/components/hsv_slider_group.dart new file mode 100644 index 00000000..37ac6a92 --- /dev/null +++ b/packages/colorpicker/lib/src/components/hsv_slider_group.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; + +final log = Logger('hsv_slider'); + +class HsvSliderGroup extends ConsumerStatefulWidget { + const HsvSliderGroup({ + super.key, + required this.initialColor, + required this.onColorChanged, + }); + + final Color initialColor; + final void Function(Color) onColorChanged; + + @override + ConsumerState createState() => _HsvSliderGroupState(); +} + +class _HsvSliderGroupState extends ConsumerState { + late HSVColor _hsvColor; + late double _alpha; + + @override + void initState() { + super.initState(); + _hsvColor = HSVColor.fromColor(widget.initialColor); + _alpha = widget.initialColor.a; + } + + @override + void didUpdateWidget(covariant HsvSliderGroup oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.initialColor != oldWidget.initialColor) { + final newHsvColor = HSVColor.fromColor(widget.initialColor); + final newAlpha = widget.initialColor.a; + + final hueEqual = (_hsvColor.hue - newHsvColor.hue).abs() < 0.01 || + (_hsvColor.hue - newHsvColor.hue).abs() > 359.99; + + final satEqual = + (_hsvColor.saturation - newHsvColor.saturation).abs() < 0.001; + final valEqual = (_hsvColor.value - newHsvColor.value).abs() < 0.001; + final alphaEqual = (_alpha - newAlpha).abs() < 0.001; + + bool needsSetState = false; + + if (!hueEqual || !satEqual || !valEqual) { + _hsvColor = newHsvColor; + needsSetState = true; + } + + if (!alphaEqual) { + _alpha = newAlpha; + needsSetState = true; + } + + if (needsSetState) { + setState(() {}); + } + } + } + + void _handleHueChanged(double hue) { + setState(() { + _hsvColor = _hsvColor.withHue(hue.clamp(0.0, 359.999)); + }); + widget.onColorChanged(_hsvColor.withAlpha(_alpha).toColor()); + } + + void _handleSaturationChanged(double saturation) { + final newSaturation = saturation.clamp(0.0, 1.0); + if ((_hsvColor.saturation - newSaturation).abs() < 0.001) return; + + setState(() { + _hsvColor = _hsvColor.withSaturation(newSaturation); + }); + widget.onColorChanged(_hsvColor.withAlpha(_alpha).toColor()); + } + + void _handleValueChanged(double value) { + setState(() { + _hsvColor = _hsvColor.withValue(value); + }); + widget.onColorChanged(_hsvColor.withAlpha(_alpha).toColor()); + } + + void _handleAlphaChanged(double alpha) { + setState(() { + _alpha = alpha; + }); + widget.onColorChanged(_hsvColor.withAlpha(_alpha).toColor()); + } + + @override + Widget build(BuildContext context) { + String hsvText = '(${_hsvColor.hue.round()}°, ' + '${(_hsvColor.saturation * 100).toStringAsFixed(0)}%, ' + '${(_hsvColor.value * 100).toStringAsFixed(0)}%)'; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text(hsvText, + style: const TextStyle(fontWeight: FontWeight.bold)), + ), + Row( + children: [ + const SizedBox( + width: 45, + child: Text('Hue:'), + ), + Expanded( + child: Slider( + value: _hsvColor.hue, + min: 0.0, + max: 359.999, + onChanged: _handleHueChanged, + ), + ), + SizedBox( + width: 45, + child: Text( + '${_hsvColor.hue.round()}°', + textAlign: TextAlign.right, + ), + ), + ], + ), + Row( + children: [ + const SizedBox( + width: 45, + child: Text('Sat:'), + ), + Expanded( + child: Slider( + value: _hsvColor.saturation, + min: 0.0, + max: 1.0, + onChanged: _handleSaturationChanged, + ), + ), + SizedBox( + width: 55, + child: Text( + '${(_hsvColor.saturation * 100).toStringAsFixed(0)}%', + textAlign: TextAlign.right, + ), + ), + ], + ), + Row( + children: [ + const SizedBox( + width: 45, + child: Text('Val:'), + ), + Expanded( + child: Slider( + value: _hsvColor.value, + min: 0.0, + max: 1.0, + onChanged: _handleValueChanged, + ), + ), + SizedBox( + width: 55, + child: Text( + '${(_hsvColor.value * 100).toStringAsFixed(0)}%', + textAlign: TextAlign.right, + ), + ), + ], + ), + Row( + children: [ + const SizedBox( + width: 45, + child: Text('Alpha:'), + ), + Expanded( + child: Slider( + value: _alpha, + min: 0.0, + max: 1.0, + onChanged: _handleAlphaChanged, + ), + ), + SizedBox( + width: 55, + child: Text( + '${(_alpha * 100).toStringAsFixed(0)}%', + textAlign: TextAlign.right, + ), + ), + ], + ), + ], + ); + } +} diff --git a/packages/colorpicker/lib/src/components/hue_saturation_picker.dart b/packages/colorpicker/lib/src/components/hue_saturation_picker.dart new file mode 100644 index 00000000..7b02b01d --- /dev/null +++ b/packages/colorpicker/lib/src/components/hue_saturation_picker.dart @@ -0,0 +1,147 @@ +import 'dart:math'; +import 'package:colorpicker/src/components/hue_slider_painter.dart'; +import 'package:colorpicker/src/components/saturation_value_painter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class HueSaturationValuePicker extends ConsumerStatefulWidget { + final Color initialColor; + final ValueChanged onColorChanged; + + const HueSaturationValuePicker({ + Key? key, + required this.initialColor, + required this.onColorChanged, + }) : super(key: key); + + @override + ConsumerState createState() => + _HueSaturationValuePickerState(); +} + +class _HueSaturationValuePickerState + extends ConsumerState { + late HSVColor _currentHsvColor; + late Offset _svThumbPosition; + late double _hueSliderThumbY; + + double _currentPickerSquareSize = 0.0; + + @override + void initState() { + super.initState(); + _currentHsvColor = HSVColor.fromColor(widget.initialColor); + _svThumbPosition = Offset.zero; + _hueSliderThumbY = 0.0; + } + + @override + void didUpdateWidget(HueSaturationValuePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialColor != oldWidget.initialColor) { + final newHsv = HSVColor.fromColor(widget.initialColor); + if (newHsv != _currentHsvColor) { + _currentHsvColor = newHsv; + _updateThumbPositions( + _currentPickerSquareSize, _currentPickerSquareSize); + } + } + } + + void _updateThumbPositions(double pickerSquareSize, double hueSliderHeight) { + _currentPickerSquareSize = pickerSquareSize; + + _svThumbPosition = Offset( + _currentHsvColor.saturation * pickerSquareSize, + (1.0 - _currentHsvColor.value) * pickerSquareSize, + ); + _hueSliderThumbY = (_currentHsvColor.hue / 360.0) * hueSliderHeight; + if (mounted) { + setState(() {}); + } + } + + void _handleSaturationValueChange( + Offset localPosition, double pickerSquareSize) { + double s = (localPosition.dx / pickerSquareSize).clamp(0.0, 1.0); + double v = (1.0 - (localPosition.dy / pickerSquareSize)).clamp(0.0, 1.0); + setState(() { + _currentHsvColor = _currentHsvColor.withSaturation(s).withValue(v); + _updateThumbPositions(pickerSquareSize, pickerSquareSize); + }); + widget.onColorChanged(_currentHsvColor.toColor()); + } + + void _handleHueChange(Offset localPosition, double hueSliderHeight) { + double hue = + (localPosition.dy / hueSliderHeight * 360.0).clamp(0.0, 359.999); + setState(() { + _currentHsvColor = _currentHsvColor.withHue(hue); + _updateThumbPositions(_currentPickerSquareSize, hueSliderHeight); + }); + widget.onColorChanged(_currentHsvColor.toColor()); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final double pickerSquareSize = constraints.maxWidth * 0.7; + final double hueSliderWidth = max(25.0, pickerSquareSize * 0.12); + final double spacing = pickerSquareSize * 0.05; + + if (pickerSquareSize <= 0 || constraints.maxWidth <= 0) { + return const SizedBox.shrink(); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && + (_currentPickerSquareSize != pickerSquareSize || + _svThumbPosition == Offset.zero && _hueSliderThumbY == 0.0)) { + _updateThumbPositions(pickerSquareSize, pickerSquareSize); + } + }); + + if (pickerSquareSize <= 0) { + return const SizedBox.shrink(); + } + + return SizedBox( + width: pickerSquareSize + hueSliderWidth + spacing, + height: pickerSquareSize, + child: Row( + children: [ + GestureDetector( + onPanDown: (details) => _handleSaturationValueChange( + details.localPosition, pickerSquareSize), + onPanUpdate: (details) => _handleSaturationValueChange( + details.localPosition, pickerSquareSize), + child: CustomPaint( + size: Size(pickerSquareSize, pickerSquareSize), + painter: SaturationValuePainter( + hue: _currentHsvColor.hue, + thumbPosition: _svThumbPosition, + ), + ), + ), + SizedBox(width: spacing), + GestureDetector( + onPanDown: (details) => + _handleHueChange(details.localPosition, pickerSquareSize), + onPanUpdate: (details) => + _handleHueChange(details.localPosition, pickerSquareSize), + child: CustomPaint( + size: Size(hueSliderWidth, pickerSquareSize), + painter: HueSliderPainter( + hueSliderThumbY: _hueSliderThumbY, + thumbColor: _currentHsvColor.toColor(), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/packages/colorpicker/lib/src/components/hue_slider_painter.dart b/packages/colorpicker/lib/src/components/hue_slider_painter.dart new file mode 100644 index 00000000..8591f679 --- /dev/null +++ b/packages/colorpicker/lib/src/components/hue_slider_painter.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +class HueSliderPainter extends CustomPainter { + final double hueSliderThumbY; + final Color thumbColor; + + HueSliderPainter({required this.hueSliderThumbY, required this.thumbColor}); + + @override + void paint(Canvas canvas, Size size) { + final Rect rect = Offset.zero & size; + + final List hueColors = List.generate( + 360, + (i) => HSVColor.fromAHSV(1.0, i.toDouble(), 1.0, 1.0).toColor(), + ); + final Paint huePaint = Paint() + ..shader = LinearGradient( + colors: hueColors, + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ).createShader(rect); + canvas.drawRect(rect, huePaint); + + const double thumbWidthFactor = 0.1; + const double thumbHeightFactor = 0.05; + final double thumbHeight = size.height * thumbHeightFactor; + + final Paint thumbPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + final Paint thumbBorderPaint = Paint() + ..color = Colors.black54 + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + + final double clampedThumbY = + hueSliderThumbY.clamp(thumbHeight / 2, size.height - (thumbHeight / 2)); + final Rect thumbRect = Rect.fromCenter( + center: Offset(size.width / 2, clampedThumbY), + width: size.width + (size.width * thumbWidthFactor), + height: thumbHeight); + + final RRect thumbRRect = + RRect.fromRectAndRadius(thumbRect, const Radius.circular(2.0)); + canvas.drawRRect(thumbRRect, thumbPaint); + canvas.drawRRect(thumbRRect, thumbBorderPaint); + } + + @override + bool shouldRepaint(HueSliderPainter oldDelegate) { + return oldDelegate.hueSliderThumbY != hueSliderThumbY || + oldDelegate.thumbColor != thumbColor; + } +} diff --git a/packages/colorpicker/lib/src/components/opacity_slider.dart b/packages/colorpicker/lib/src/components/opacity_slider.dart index dfc9349b..be937519 100644 --- a/packages/colorpicker/lib/src/components/opacity_slider.dart +++ b/packages/colorpicker/lib/src/components/opacity_slider.dart @@ -18,6 +18,10 @@ class OpacitySlider extends ConsumerWidget { return Container( height: 25.0, decoration: BoxDecoration( + border: Border.all( + color: Colors.grey, + width: 1.0, + ), image: DecorationImage( image: PackageAssets.getCheckerboardImgAsset(), fit: BoxFit.fitHeight, @@ -28,8 +32,8 @@ class OpacitySlider extends ConsumerWidget { decoration: BoxDecoration( gradient: LinearGradient( colors: [ - gradientColor..withValues(alpha: 1.0), - gradientColor..withValues(alpha: 0.0), + gradientColor.withAlpha(255), + gradientColor.withAlpha(0), ], begin: Alignment.centerLeft, end: Alignment.centerRight, diff --git a/packages/colorpicker/lib/src/components/picker_content_widget.dart b/packages/colorpicker/lib/src/components/picker_content_widget.dart new file mode 100644 index 00000000..1320be60 --- /dev/null +++ b/packages/colorpicker/lib/src/components/picker_content_widget.dart @@ -0,0 +1,45 @@ +import 'package:colorpicker/src/components/advanced_picker_widget.dart'; +import 'package:colorpicker/src/components/grid_picker_widget.dart'; +import 'package:colorpicker/src/components/hsv_rgb_sliders_picker_widget.dart'; +import 'package:colorpicker/src/enums/main_picker_mode_type.dart'; +import 'package:colorpicker/src/state/color_picker_state_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class PickerContentWidget extends ConsumerWidget { + final MainPickerMode mainPickerMode; + final Color colorForPickers; + final void Function(Color) onColorChanged; + final void Function(Color) onColorAndOpacityChanged; + + const PickerContentWidget({ + super.key, + required this.mainPickerMode, + required this.colorForPickers, + required this.onColorChanged, + required this.onColorAndOpacityChanged, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentGlobalOpacity = ref + .read(colorPickerStateProvider.select((state) => state.currentOpacity)); + final colorForHsvRgbWithOpacity = + colorForPickers.withAlpha((currentGlobalOpacity * 255).round()); + + switch (mainPickerMode) { + case MainPickerMode.grid: + return GridPickerWidget(onColorSelected: onColorChanged); + case MainPickerMode.advanced: + return AdvancedPickerWidget( + colorForPickers: colorForPickers, + onColorChanged: onColorChanged, + text1: 'Picker', + text2: 'Wheel'); + case MainPickerMode.sliders: + return HsvRgbSlidersPickerWidget( + initialColor: colorForHsvRgbWithOpacity, + onColorChanged: onColorAndOpacityChanged); + } + } +} diff --git a/packages/colorpicker/lib/src/components/recent_colors_section_widget.dart b/packages/colorpicker/lib/src/components/recent_colors_section_widget.dart new file mode 100644 index 00000000..21a9531f --- /dev/null +++ b/packages/colorpicker/lib/src/components/recent_colors_section_widget.dart @@ -0,0 +1,72 @@ +import 'package:colorpicker/src/state/recent_color_state_provider.dart'; +import 'package:colorpicker/utils/assets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class RecentColorsSectionWidget extends ConsumerWidget { + final void Function(Color) onColorSelected; + + const RecentColorsSectionWidget({super.key, required this.onColorSelected}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final recentColors = ref.watch(recentColorsProvider); + final theme = Theme.of(context); + + if (recentColors.isEmpty) { + return const SizedBox.shrink(); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 15.0), + Wrap( + spacing: 8.0, + runSpacing: 8.0, + children: recentColors.map((color) { + final bool needsCheckerboard = color.a < 1.0; + + return GestureDetector( + onTap: () { + onColorSelected(color); + }, + child: Container( + width: 32.0, + height: 32.0, + decoration: BoxDecoration( + image: needsCheckerboard + ? DecorationImage( + image: PackageAssets.getCheckerboardImgAsset(), + repeat: ImageRepeat.repeat, + ) + : null, + borderRadius: BorderRadius.circular(4.0), + border: Border.all(color: theme.dividerColor, width: 1.0), + ), + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4.0), + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 8.0), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'recently used', + style: TextStyle( + fontSize: 12.0, + fontWeight: FontWeight.bold, + color: + theme.textTheme.bodySmall?.color?.withAlpha((178.5).toInt()), + ), + ), + ), + ], + ); + } +} diff --git a/packages/colorpicker/lib/src/components/rgb_slider_group.dart b/packages/colorpicker/lib/src/components/rgb_slider_group.dart new file mode 100644 index 00000000..7210b01b --- /dev/null +++ b/packages/colorpicker/lib/src/components/rgb_slider_group.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:colorpicker/src/constants/colorpicker_colors.dart'; +import 'package:logging/logging.dart'; +import 'package:colorpicker/src/components/color_slider_row_widget.dart'; +import 'package:colorpicker/src/components/hex_input_row_widget.dart'; + +final log = Logger('RgbSliderGroup'); + +class RgbSliderGroup extends ConsumerStatefulWidget { + final Color initialColor; + final ValueChanged onColorChanged; + + const RgbSliderGroup({ + super.key, + required this.initialColor, + required this.onColorChanged, + }); + + @override + ConsumerState createState() => _SliderColorState(); +} + +class _SliderColorState extends ConsumerState { + late int _red; + late int _green; + late int _blue; + late int _alphaValue; + late TextEditingController _hexController; + late FocusNode _hexFocusNode; + + int _getAlpha(Color color) => (color.a * 255).round(); + + int _getRed(Color color) => (color.r * 255).round(); + + int _getGreen(Color color) => (color.g * 255).round(); + + int _getBlue(Color color) => (color.b * 255).round(); + + @override + void initState() { + super.initState(); + _hexController = TextEditingController(); + _hexFocusNode = FocusNode(); + _initializeStateFromColor(widget.initialColor); + } + + @override + void didUpdateWidget(RgbSliderGroup oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialColor != oldWidget.initialColor) { + _initializeStateFromColor(widget.initialColor); + } + } + + void _initializeStateFromColor(Color color) { + _red = _getRed(color); + _green = _getGreen(color); + _blue = _getBlue(color); + _alphaValue = _getAlpha(color); + _hexController.text = _colorToHex(color); + } + + String _colorToHex(Color color) { + return '#${_getAlpha(color).toRadixString(16).padLeft(2, '0')}${_getRed(color).toRadixString(16).padLeft(2, '0')}${_getGreen(color).toRadixString(16).padLeft(2, '0')}${_getBlue(color).toRadixString(16).padLeft(2, '0')}' + .toUpperCase(); + } + + Color? _tryParseHex(String hexString) { + String hex = hexString.toUpperCase().replaceFirst('#', ''); + if (hex.length == 3) { + hex = 'FF${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}'; + } + if (hex.length == 4) { + String a = hex[0] + hex[0]; + String r = hex[1] + hex[1]; + String g = hex[2] + hex[2]; + String b = hex[3] + hex[3]; + hex = a + r + g + b; + } + if (hex.length == 6) { + hex = 'FF$hex'; + } + if (hex.length == 8) { + try { + return Color(int.parse(hex, radix: 16)); + } catch (e, stackTrace) { + log.warning('Failed to parse hex string: $hexString', e, stackTrace); + } + } + return null; + } + + void _updateColorFromSliders() { + final newColor = Color.fromARGB(_alphaValue, _red, _green, _blue); + final newHex = _colorToHex(newColor); + if (mounted && + !_hexFocusNode.hasFocus && + _hexController.text.toUpperCase() != newHex.toUpperCase()) { + _hexController.text = newHex; + } + widget.onColorChanged(newColor); + } + + void _updateColorFromHex(String hexValue) { + final Color? newColor = _tryParseHex(hexValue); + if (newColor != null) { + bool needsSetState = false; + if (_red != _getRed(newColor)) { + _red = _getRed(newColor); + needsSetState = true; + } + if (_green != _getGreen(newColor)) { + _green = _getGreen(newColor); + needsSetState = true; + } + if (_blue != _getBlue(newColor)) { + _blue = _getBlue(newColor); + needsSetState = true; + } + if (_alphaValue != _getAlpha(newColor)) { + _alphaValue = _getAlpha(newColor); + needsSetState = true; + } + + if (mounted && needsSetState) { + setState(() {}); + } + widget.onColorChanged(newColor); + } + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ColorSliderRowWidget( + label: 'Red', + value: _red.toDouble(), + min: 0, + max: 255, + onChanged: (value) { + if (_red != value.round()) { + if (mounted) { + setState(() { + _red = value.round(); + }); + } + _updateColorFromSliders(); + } + }, + sliderActiveColor: Colors.red, + ), + const SizedBox(height: 10), + ColorSliderRowWidget( + label: 'Green', + value: _green.toDouble(), + min: 0, + max: 255, + onChanged: (value) { + if (_green != value.round()) { + if (mounted) { + setState(() { + _green = value.round(); + }); + } + _updateColorFromSliders(); + } + }, + sliderActiveColor: Colors.green, + ), + const SizedBox(height: 10), + ColorSliderRowWidget( + label: 'Blue', + value: _blue.toDouble(), + min: 0, + max: 255, + onChanged: (value) { + if (_blue != value.round()) { + if (mounted) { + setState(() { + _blue = value.round(); + }); + } + _updateColorFromSliders(); + } + }, + sliderActiveColor: Colors.blue, + ), + const SizedBox(height: 10), + ColorSliderRowWidget( + label: 'Alpha', + value: _alphaValue.toDouble(), + min: 0, + max: 255, + onChanged: (value) { + if (_alphaValue != value.round()) { + if (mounted) { + setState(() { + _alphaValue = value.round(); + }); + } + _updateColorFromSliders(); + } + }, + sliderActiveColor: ColorPickerColors.oceanBlue, + isAlpha: true, + ), + const SizedBox(height: 16.0), + HexInputRowWidget( + hexController: _hexController, + hexFocusNode: _hexFocusNode, + onSubmitted: _updateColorFromHex, + onEditingComplete: () { + _updateColorFromHex(_hexController.text); + if (mounted) _hexFocusNode.unfocus(); + }, + ), + ], + ); + } + + @override + void dispose() { + _hexController.dispose(); + _hexFocusNode.dispose(); + super.dispose(); + } +} diff --git a/packages/colorpicker/lib/src/components/saturation_value_painter.dart b/packages/colorpicker/lib/src/components/saturation_value_painter.dart new file mode 100644 index 00000000..e47a7e21 --- /dev/null +++ b/packages/colorpicker/lib/src/components/saturation_value_painter.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +class SaturationValuePainter extends CustomPainter { + final double hue; + final Offset thumbPosition; + + SaturationValuePainter({required this.hue, required this.thumbPosition}); + + @override + void paint(Canvas canvas, Size size) { + final Rect rect = Offset.zero & size; + + final Paint paintWhiteToHue = Paint() + ..shader = LinearGradient( + colors: [ + HSVColor.fromAHSV(1.0, hue, 0.0, 1.0).toColor(), + HSVColor.fromAHSV(1.0, hue, 1.0, 1.0).toColor(), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ).createShader(rect); + canvas.drawRect(rect, paintWhiteToHue); + + final Paint paintTransparentToBlack = Paint() + ..shader = const LinearGradient( + colors: [ + Colors.transparent, + Colors.black, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ).createShader(rect); + canvas.drawRect(rect, paintTransparentToBlack); + + final Paint thumbPaintFill = Paint()..color = Colors.white; + final Paint thumbPaintStroke = Paint() + ..color = Colors.black54 + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + const double thumbRadius = 6.0; + + final double clampedDx = + thumbPosition.dx.clamp(thumbRadius, size.width - thumbRadius); + final double clampedDy = + thumbPosition.dy.clamp(thumbRadius, size.height - thumbRadius); + final Offset clampedThumbPosition = Offset(clampedDx, clampedDy); + + canvas.drawCircle(clampedThumbPosition, thumbRadius, thumbPaintFill); + canvas.drawCircle(clampedThumbPosition, thumbRadius, thumbPaintStroke); + } + + @override + bool shouldRepaint(SaturationValuePainter oldDelegate) { + return oldDelegate.hue != hue || oldDelegate.thumbPosition != thumbPosition; + } +} diff --git a/packages/colorpicker/lib/src/components/slider_indicator_shape.dart b/packages/colorpicker/lib/src/components/slider_indicator_shape.dart index 4cf110be..5894a297 100644 --- a/packages/colorpicker/lib/src/components/slider_indicator_shape.dart +++ b/packages/colorpicker/lib/src/components/slider_indicator_shape.dart @@ -23,14 +23,25 @@ class SliderIndicatorShape extends SliderComponentShape { double? textScaleFactor, Size? sizeWithOverflow, }) { - final Canvas canvas = context.canvas; + final canvas = context.canvas; + const thumbWidth = 5.0; + const thumbHeight = 26.0; + double dx = center.dx; + + if (value == 0.0) { + dx = thumbWidth / 2; + } else if (value == 1.0 && parentBox != null) { + dx = parentBox.size.width - thumbWidth / 2; + } + + final rect = Rect.fromCenter( + center: Offset(dx, center.dy), + width: thumbWidth, + height: thumbHeight, + ); canvas.drawRect( - Rect.fromCenter( - center: center, - width: 5.0, - height: 26.0, - ), + rect, Paint() ..color = const Color.fromARGB(255, 62, 62, 62) ..style = PaintingStyle.stroke diff --git a/packages/colorpicker/lib/src/components/toggle_item_widget.dart b/packages/colorpicker/lib/src/components/toggle_item_widget.dart new file mode 100644 index 00000000..93cd127b --- /dev/null +++ b/packages/colorpicker/lib/src/components/toggle_item_widget.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class ToggleItemWidget extends StatelessWidget { + final String text; + final T currentMode; + final T buttonMode; + + const ToggleItemWidget({ + super.key, + required this.text, + required this.currentMode, + required this.buttonMode, + }); + + @override + Widget build(BuildContext context) { + final bool isSelected = currentMode == buttonMode; + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isSelected) + Icon( + Icons.check, + size: 18.0, + color: colorScheme.onSurface, + ), + if (isSelected) const SizedBox(width: 6.0), + Text( + text, + style: TextStyle(color: colorScheme.onSurface), + ), + ], + ), + ); + } +} diff --git a/packages/colorpicker/lib/src/constants/color_picker_constants.dart b/packages/colorpicker/lib/src/constants/color_picker_constants.dart new file mode 100644 index 00000000..bbe8eb53 --- /dev/null +++ b/packages/colorpicker/lib/src/constants/color_picker_constants.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +const SweepGradient hueSweepGradient = SweepGradient( + colors: [ + Color(0xFFFF0000), + Color(0xFFFFFF00), + Color(0xFF00FF00), + Color(0xFF00FFFF), + Color(0xFF0000FF), + Color(0xFFFF00FF), + Color(0xFFFF0000), + ], +); diff --git a/packages/colorpicker/lib/src/constants/colorpicker_colors.dart b/packages/colorpicker/lib/src/constants/colorpicker_colors.dart new file mode 100644 index 00000000..d61b2380 --- /dev/null +++ b/packages/colorpicker/lib/src/constants/colorpicker_colors.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; + +class ColorPickerColors { + static const Color oceanBlue = Color(0xFF0077C2); + static const Color orange = Colors.orange; +} diff --git a/packages/colorpicker/lib/src/constants/painter_thumb_constants.dart b/packages/colorpicker/lib/src/constants/painter_thumb_constants.dart new file mode 100644 index 00000000..11d878ac --- /dev/null +++ b/packages/colorpicker/lib/src/constants/painter_thumb_constants.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +const Color kThumbFillColor = Colors.white; +const Color kThumbStrokeColor = Colors.black54; +const Color kColorWheelThumbStrokeColor = Colors.black54; + +const double kSaturationValueThumbStrokeWidth = 1.5; +const double kHueSliderThumbStrokeWidth = 1.0; +const double kColorWheelThumbStrokeWidth = 1.0; diff --git a/packages/colorpicker/lib/src/enums/advanced_picker_mode_type.dart b/packages/colorpicker/lib/src/enums/advanced_picker_mode_type.dart new file mode 100644 index 00000000..16162cc3 --- /dev/null +++ b/packages/colorpicker/lib/src/enums/advanced_picker_mode_type.dart @@ -0,0 +1 @@ +enum AdvancedPickerMode { picker, wheel } diff --git a/packages/colorpicker/lib/src/enums/main_picker_mode_type.dart b/packages/colorpicker/lib/src/enums/main_picker_mode_type.dart new file mode 100644 index 00000000..4bdd5688 --- /dev/null +++ b/packages/colorpicker/lib/src/enums/main_picker_mode_type.dart @@ -0,0 +1 @@ +enum MainPickerMode { grid, advanced, sliders } diff --git a/packages/colorpicker/lib/src/enums/slider_picker_mode_type.dart b/packages/colorpicker/lib/src/enums/slider_picker_mode_type.dart new file mode 100644 index 00000000..74f28975 --- /dev/null +++ b/packages/colorpicker/lib/src/enums/slider_picker_mode_type.dart @@ -0,0 +1 @@ +enum SliderPickerMode { hsv, rgb } diff --git a/packages/colorpicker/lib/src/state/color_picker_state_data.freezed.dart b/packages/colorpicker/lib/src/state/color_picker_state_data.freezed.dart index 165b8aa7..a86f782c 100644 --- a/packages/colorpicker/lib/src/state/color_picker_state_data.freezed.dart +++ b/packages/colorpicker/lib/src/state/color_picker_state_data.freezed.dart @@ -12,14 +12,16 @@ part of 'color_picker_state_data.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); /// @nodoc mixin _$ColorPickerStateData { Color? get currentColor => throw _privateConstructorUsedError; double get currentOpacity => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + /// Create a copy of ColorPickerStateData + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $ColorPickerStateDataCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -44,6 +46,8 @@ class _$ColorPickerStateDataCopyWithImpl<$Res, // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of ColorPickerStateData + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -64,31 +68,33 @@ class _$ColorPickerStateDataCopyWithImpl<$Res, } /// @nodoc -abstract class _$$_ColorPickerStateDataCopyWith<$Res> +abstract class _$$ColorPickerStateDataImplCopyWith<$Res> implements $ColorPickerStateDataCopyWith<$Res> { - factory _$$_ColorPickerStateDataCopyWith(_$_ColorPickerStateData value, - $Res Function(_$_ColorPickerStateData) then) = - __$$_ColorPickerStateDataCopyWithImpl<$Res>; + factory _$$ColorPickerStateDataImplCopyWith(_$ColorPickerStateDataImpl value, + $Res Function(_$ColorPickerStateDataImpl) then) = + __$$ColorPickerStateDataImplCopyWithImpl<$Res>; @override @useResult $Res call({Color? currentColor, double currentOpacity}); } /// @nodoc -class __$$_ColorPickerStateDataCopyWithImpl<$Res> - extends _$ColorPickerStateDataCopyWithImpl<$Res, _$_ColorPickerStateData> - implements _$$_ColorPickerStateDataCopyWith<$Res> { - __$$_ColorPickerStateDataCopyWithImpl(_$_ColorPickerStateData _value, - $Res Function(_$_ColorPickerStateData) _then) +class __$$ColorPickerStateDataImplCopyWithImpl<$Res> + extends _$ColorPickerStateDataCopyWithImpl<$Res, _$ColorPickerStateDataImpl> + implements _$$ColorPickerStateDataImplCopyWith<$Res> { + __$$ColorPickerStateDataImplCopyWithImpl(_$ColorPickerStateDataImpl _value, + $Res Function(_$ColorPickerStateDataImpl) _then) : super(_value, _then); + /// Create a copy of ColorPickerStateData + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ Object? currentColor = freezed, Object? currentOpacity = null, }) { - return _then(_$_ColorPickerStateData( + return _then(_$ColorPickerStateDataImpl( currentColor: freezed == currentColor ? _value.currentColor : currentColor // ignore: cast_nullable_to_non_nullable @@ -103,8 +109,8 @@ class __$$_ColorPickerStateDataCopyWithImpl<$Res> /// @nodoc -class _$_ColorPickerStateData implements _ColorPickerStateData { - const _$_ColorPickerStateData( +class _$ColorPickerStateDataImpl implements _ColorPickerStateData { + const _$ColorPickerStateDataImpl( {required this.currentColor, required this.currentOpacity}); @override @@ -121,7 +127,7 @@ class _$_ColorPickerStateData implements _ColorPickerStateData { bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$_ColorPickerStateData && + other is _$ColorPickerStateDataImpl && (identical(other.currentColor, currentColor) || other.currentColor == currentColor) && (identical(other.currentOpacity, currentOpacity) || @@ -131,25 +137,31 @@ class _$_ColorPickerStateData implements _ColorPickerStateData { @override int get hashCode => Object.hash(runtimeType, currentColor, currentOpacity); - @JsonKey(ignore: true) + /// Create a copy of ColorPickerStateData + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') - _$$_ColorPickerStateDataCopyWith<_$_ColorPickerStateData> get copyWith => - __$$_ColorPickerStateDataCopyWithImpl<_$_ColorPickerStateData>( - this, _$identity); + _$$ColorPickerStateDataImplCopyWith<_$ColorPickerStateDataImpl> + get copyWith => + __$$ColorPickerStateDataImplCopyWithImpl<_$ColorPickerStateDataImpl>( + this, _$identity); } abstract class _ColorPickerStateData implements ColorPickerStateData { const factory _ColorPickerStateData( {required final Color? currentColor, - required final double currentOpacity}) = _$_ColorPickerStateData; + required final double currentOpacity}) = _$ColorPickerStateDataImpl; @override Color? get currentColor; @override double get currentOpacity; + + /// Create a copy of ColorPickerStateData + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) - _$$_ColorPickerStateDataCopyWith<_$_ColorPickerStateData> get copyWith => - throw _privateConstructorUsedError; + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ColorPickerStateDataImplCopyWith<_$ColorPickerStateDataImpl> + get copyWith => throw _privateConstructorUsedError; } diff --git a/packages/colorpicker/lib/src/state/color_picker_state_provider.g.dart b/packages/colorpicker/lib/src/state/color_picker_state_provider.g.dart index 2ac4146f..f2d12dc6 100644 --- a/packages/colorpicker/lib/src/state/color_picker_state_provider.g.dart +++ b/packages/colorpicker/lib/src/state/color_picker_state_provider.g.dart @@ -23,4 +23,4 @@ final colorPickerStateProvider = AutoDisposeNotifierProvider; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/packages/colorpicker/lib/src/state/recent_color_state_provider.dart b/packages/colorpicker/lib/src/state/recent_color_state_provider.dart new file mode 100644 index 00000000..bf23e7f9 --- /dev/null +++ b/packages/colorpicker/lib/src/state/recent_color_state_provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'recent_color_state_provider.g.dart'; + +const int _maxRecentColors = 6; + +@Riverpod(keepAlive: true) +class RecentColors extends _$RecentColors { + @override + List build() { + return []; + } + + void addColor(Color color) { + final currentState = List.from(state); + currentState.removeWhere((existingColor) => existingColor == color); + currentState.insert(0, color); + + if (currentState.length > _maxRecentColors) { + state = currentState.sublist(0, _maxRecentColors); + } else { + state = currentState; + } + } +} diff --git a/packages/colorpicker/lib/src/state/recent_color_state_provider.g.dart b/packages/colorpicker/lib/src/state/recent_color_state_provider.g.dart new file mode 100644 index 00000000..934aae73 --- /dev/null +++ b/packages/colorpicker/lib/src/state/recent_color_state_provider.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recent_color_state_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$recentColorsHash() => r'b18f8c372b35ff0a15f8aa9e3c51eb1779518120'; + +/// See also [RecentColors]. +@ProviderFor(RecentColors) +final recentColorsProvider = + NotifierProvider>.internal( + RecentColors.new, + name: r'recentColorsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$recentColorsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$RecentColors = Notifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/packages/colorpicker/lib/src/utils/hex_input_formatter.dart b/packages/colorpicker/lib/src/utils/hex_input_formatter.dart new file mode 100644 index 00000000..1f67e404 --- /dev/null +++ b/packages/colorpicker/lib/src/utils/hex_input_formatter.dart @@ -0,0 +1,35 @@ +import 'package:flutter/services.dart'; + +class HexInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, TextEditingValue newValue) { + String text = newValue.text; + String filteredText = text.replaceAll(RegExp(r'[^0-9A-Fa-f#]'), ''); + + if (filteredText.isNotEmpty && filteredText[0] != '#') { + filteredText = '#$filteredText'; + } else if (filteredText.length > 1 && + filteredText.substring(1).contains('#')) { + filteredText = oldValue.text; + } else if (filteredText.isEmpty && + oldValue.text.isNotEmpty && + text.isNotEmpty) { + return TextEditingValue.empty; + } else if (filteredText.isEmpty && text.isNotEmpty) { + return oldValue; + } + + if (filteredText.length > 9) { + filteredText = filteredText.substring(0, 9); + } + + if (filteredText != newValue.text) { + return TextEditingValue( + text: filteredText, + selection: TextSelection.collapsed(offset: filteredText.length), + ); + } + return newValue; + } +} diff --git a/packages/colorpicker/lib/src/utils/upper_case_text_formatter.dart b/packages/colorpicker/lib/src/utils/upper_case_text_formatter.dart new file mode 100644 index 00000000..3cae183c --- /dev/null +++ b/packages/colorpicker/lib/src/utils/upper_case_text_formatter.dart @@ -0,0 +1,12 @@ +import 'package:flutter/services.dart'; + +class UpperCaseTextFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, TextEditingValue newValue) { + return TextEditingValue( + text: newValue.text.toUpperCase(), + selection: newValue.selection, + ); + } +} diff --git a/packages/colorpicker/pubspec.yaml b/packages/colorpicker/pubspec.yaml index cbd18409..5ab8ebe6 100644 --- a/packages/colorpicker/pubspec.yaml +++ b/packages/colorpicker/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: flutter_riverpod: ^2.3.6 riverpod_annotation: ^2.1.1 freezed_annotation: ^2.4.1 + logging: ^1.0.2 dev_dependencies: flutter_test: @@ -28,4 +29,7 @@ flutter: uses-material-design: true assets: - - assets/img/ \ No newline at end of file + - assets/img/ + +dependency_overrides: + analyzer_plugin: ^0.13.0