import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../widgets/connection_status.dart'; import '../../../core/config/app_bubble_behavior.dart'; import '../../../core/config/app_bubble_shape.dart'; import '../../../core/config/app_pill_gradient.dart'; import '../../../core/config/app_visual_style.dart'; import '../../../core/config/app_chat_chrome.dart'; import '../../../core/config/app_composer_background.dart'; import '../../../core/config/app_composer_style.dart'; import '../../../core/config/app_nav_pill_style.dart'; import '../../../core/config/app_spectrum_background.dart'; import '../../../core/utils/bubble_radius.dart'; import '../../../core/utils/debouncer.dart'; import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/liquid_glass.dart'; import '../../widgets/settings_card.dart'; import '../../../core/config/app_shape.dart'; class AppearanceScreen extends StatefulWidget { const AppearanceScreen({super.key}); @override State createState() => _AppearanceScreenState(); } class _AppearanceScreenState extends State { static const _fallback = Color(0xFFC1C4FF); final ValueNotifier _color = ValueNotifier(_fallback); final ValueNotifier _isSystem = ValueNotifier(false); bool _initialized = false; bool _accentExpanded = false; final _debounce = Debouncer(const Duration(milliseconds: 350)); @override void didChangeDependencies() { super.didChangeDependencies(); if (!_initialized) { _initialized = true; final seed = KometApp.stateOf(context)?.accentSeed.value; _isSystem.value = seed == null; _color.value = seed ?? _fallback; } } @override void dispose() { _debounce.dispose(); _color.dispose(); _isSystem.dispose(); super.dispose(); } void _onColorChanged(Color color) { _color.value = color; _isSystem.value = false; _debounce.run(() { if (mounted) KometApp.stateOf(context)?.applyAccentColor(color); }); } void _resetToSystem() { Haptics.selection(); _debounce.cancel(); _isSystem.value = true; _color.value = _fallback; KometApp.stateOf(context)?.applyAccentColor(null); } void _toggleAccentExpanded() { Haptics.tap(); setState(() => _accentExpanded = !_accentExpanded); } void _onStyleChanged(BubbleStyle style) { Haptics.selection(); AppBubbleShape.save(style); } void _onBehaviorChanged(BubbleBehavior behavior) { Haptics.selection(); AppBubbleBehavior.save(behavior); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: cs.surface, appBar: ConnectionTitleBar( titleText: l10n.appearanceTitle, backgroundColor: cs.surface, ), body: SafeArea( top: false, child: ListView( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), children: [ _PreviewSection(color: _color, isSystem: _isSystem), const SizedBox(height: 16), _ColorPickerCard( color: _color, isSystem: _isSystem, expanded: _accentExpanded, onToggle: _toggleAccentExpanded, onColorChanged: _onColorChanged, onReset: _resetToSystem, ), const SizedBox(height: 12), _BubbleShapeCard(onChanged: _onStyleChanged), const SizedBox(height: 12), _BubbleBehaviorCard(onChanged: _onBehaviorChanged), const SizedBox(height: 12), const _VisualStyleCard(), const SizedBox(height: 12), const _ChatChromeCard(), const SizedBox(height: 12), const _ComposerBarCard(), const SizedBox(height: 12), const _NavPillStyleCard(), const SizedBox(height: 12), const _GradientToggleCard(), const SizedBox(height: 12), const _SpectrumToggleCard(), ], ), ), ); } } void _applyVisualStyle(VisualStyle style) { AppVisualStyle.save(style); if (style == VisualStyle.liquidGlass) { if (AppNavPillStyle.current.value != NavPillStyle.auto) { AppNavPillStyle.save(NavPillStyle.liquidGlass); } AppComposerBackground.save(ComposerBackground.liquidGlass); AppChatChrome.save(ChatChromeStyle.liquidGlass); return; } if (AppNavPillStyle.current.value == NavPillStyle.liquidGlass) { AppNavPillStyle.save(NavPillStyle.frostBlur); } if (AppComposerBackground.current.value == ComposerBackground.liquidGlass) { AppComposerBackground.save(ComposerBackground.frostBlur); } if (AppChatChrome.current.value == ChatChromeStyle.liquidGlass) { AppChatChrome.save(ChatChromeStyle.transparent); } } class _VisualStyleCard extends StatelessWidget { const _VisualStyleCard(); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return SettingsPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.appearanceVisualStyleTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), Text( l10n.appearanceVisualStyleSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 16), ValueListenableBuilder( valueListenable: AppVisualStyle.current, builder: (context, current, _) { final selectable = current == VisualStyle.liquidGlass && !LiquidGlass.isSupported ? VisualStyle.glossy : current; return SegmentedButton( showSelectedIcon: false, segments: [ ButtonSegment( value: VisualStyle.materialYou, label: Text(l10n.appearanceVisualStyleMaterialYou), ), ButtonSegment( value: VisualStyle.glossy, label: Text(l10n.appearanceVisualStyleGlossy), ), if (LiquidGlass.isSupported) ButtonSegment( value: VisualStyle.liquidGlass, label: Text(l10n.appearanceVisualStyleLiquidGlass), ), ], selected: {selectable}, onSelectionChanged: (set) { if (set.isNotEmpty) { Haptics.selection(); _applyVisualStyle(set.first); } }, ); }, ), ], ), ); } } class _ChatChromeCard extends StatelessWidget { const _ChatChromeCard(); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return SettingsPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.appearanceChatChromeTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), Text( l10n.appearanceChatChromeSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 16), ValueListenableBuilder( valueListenable: AppChatChrome.current, builder: (context, current, _) { final selectable = current == ChatChromeStyle.liquidGlass && !LiquidGlass.isSupported ? ChatChromeStyle.transparent : current; return SingleChildScrollView( scrollDirection: Axis.horizontal, child: SegmentedButton( showSelectedIcon: false, segments: [ ButtonSegment( value: ChatChromeStyle.color, label: Text(l10n.appearanceChatChromeColor), ), ButtonSegment( value: ChatChromeStyle.blur, label: Text(l10n.appearanceChatChromeBlur), ), ButtonSegment( value: ChatChromeStyle.none, label: Text(l10n.appearanceChatChromeNone), ), ButtonSegment( value: ChatChromeStyle.transparent, label: Text(l10n.appearanceChatChromeTransparent), ), if (LiquidGlass.isSupported) ButtonSegment( value: ChatChromeStyle.liquidGlass, label: Text(l10n.appearanceGlassMaterial), ), ], selected: {selectable}, onSelectionChanged: (set) { if (set.isNotEmpty) { Haptics.selection(); AppChatChrome.save(set.first); } }, ), ); }, ), ], ), ); } } class _ComposerBarCard extends StatelessWidget { const _ComposerBarCard(); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return SettingsPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.appearanceComposerTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), Text( l10n.appearanceComposerSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 16), ValueListenableBuilder( valueListenable: AppComposerStyle.current, builder: (context, current, _) { return SingleChildScrollView( scrollDirection: Axis.horizontal, child: SegmentedButton( showSelectedIcon: false, segments: [ ButtonSegment( value: ComposerStyle.auto, label: Text(l10n.appearanceStyleAuto), ), ButtonSegment( value: ComposerStyle.glossy, label: Text(l10n.appearanceVisualStyleGlossy), ), ButtonSegment( value: ComposerStyle.materialYou, label: Text(l10n.appearanceVisualStyleMaterialYou), ), ], selected: {current}, onSelectionChanged: (set) { if (set.isNotEmpty) { Haptics.selection(); AppComposerStyle.save(set.first); } }, ), ); }, ), const SizedBox(height: 10), ValueListenableBuilder( valueListenable: AppComposerBackground.current, builder: (context, current, _) { final selectable = current == ComposerBackground.liquidGlass && !LiquidGlass.isSupported ? ComposerBackground.frostBlur : current; return SegmentedButton( showSelectedIcon: false, segments: [ ButtonSegment( value: ComposerBackground.standard, label: Text(l10n.appearanceComposerBackgroundStandard), ), ButtonSegment( value: ComposerBackground.frostBlur, label: Text(l10n.appearanceComposerBackgroundFrost), ), if (LiquidGlass.isSupported) ButtonSegment( value: ComposerBackground.liquidGlass, label: Text(l10n.appearanceGlassMaterial), ), ], selected: {selectable}, onSelectionChanged: (set) { if (set.isNotEmpty) { Haptics.selection(); AppComposerBackground.save(set.first); } }, ); }, ), ], ), ); } } class _NavPillStyleCard extends StatelessWidget { const _NavPillStyleCard(); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return SettingsPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.appearanceNavPillTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), Text( l10n.appearanceNavPillSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 16), ValueListenableBuilder( valueListenable: AppNavPillStyle.current, builder: (context, current, _) { final selectable = current == NavPillStyle.liquidGlass && !LiquidGlass.isSupported ? NavPillStyle.frostBlur : current; return SingleChildScrollView( scrollDirection: Axis.horizontal, child: SegmentedButton( showSelectedIcon: false, segments: [ ButtonSegment( value: NavPillStyle.auto, label: Text(l10n.appearanceStyleAuto), ), ButtonSegment( value: NavPillStyle.glossy, label: Text(l10n.appearanceNavPillGlossy), ), ButtonSegment( value: NavPillStyle.frostBlur, label: Text(l10n.appearanceNavPillFrost), ), if (LiquidGlass.isSupported) ButtonSegment( value: NavPillStyle.liquidGlass, label: Text(l10n.appearanceGlassMaterial), ), ], selected: {selectable}, onSelectionChanged: (set) { if (set.isNotEmpty) { Haptics.selection(); AppNavPillStyle.save(set.first); } }, ), ); }, ), ], ), ); } } class _GradientToggleCard extends StatelessWidget { const _GradientToggleCard(); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return SettingsPanel( padding: const EdgeInsets.fromLTRB(20, 14, 12, 14), child: Row( children: [ Icon(Symbols.blur_on, color: cs.onSurface, size: 24, weight: 500), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.appearanceGradientTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), Text( l10n.appearanceGradientSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ], ), ), ValueListenableBuilder( valueListenable: AppPillGradient.current, builder: (context, value, _) => Switch( value: value, onChanged: (v) { Haptics.selection(); AppPillGradient.save(v); }, ), ), ], ), ); } } class _SpectrumToggleCard extends StatelessWidget { const _SpectrumToggleCard(); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return SettingsPanel( padding: const EdgeInsets.fromLTRB(20, 14, 12, 14), child: Row( children: [ Icon(Symbols.graphic_eq, color: cs.onSurface, size: 24, weight: 500), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.appearanceSpectrumTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), Text( l10n.appearanceSpectrumSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ], ), ), ValueListenableBuilder( valueListenable: AppSpectrumBackground.current, builder: (context, value, _) => Switch( value: value, onChanged: (v) { Haptics.selection(); AppSpectrumBackground.save(v); }, ), ), ], ), ); } } class _PreviewSection extends StatefulWidget { final ValueNotifier color; final ValueNotifier isSystem; const _PreviewSection({required this.color, required this.isSystem}); @override State<_PreviewSection> createState() => _PreviewSectionState(); } class _PreviewSectionState extends State<_PreviewSection> { ColorScheme? _cachedScheme; Color? _cachedColor; Brightness? _cachedBrightness; ColorScheme _schemeFor(Color color, Brightness brightness) { if (_cachedScheme != null && _cachedColor == color && _cachedBrightness == brightness) { return _cachedScheme!; } _cachedColor = color; _cachedBrightness = brightness; _cachedScheme = ColorScheme.fromSeed( seedColor: color, brightness: brightness, ); return _cachedScheme!; } @override Widget build(BuildContext context) { final outerCs = Theme.of(context).colorScheme; final brightness = Theme.of(context).brightness; return ValueListenableBuilder( valueListenable: widget.isSystem, builder: (context, isSystem, _) { if (isSystem) { return Theme( data: Theme.of(context).copyWith(colorScheme: outerCs), child: const _ChatPreview(), ); } return ValueListenableBuilder( valueListenable: widget.color, builder: (context, color, _) { return Theme( data: Theme.of( context, ).copyWith(colorScheme: _schemeFor(color, brightness)), child: const _ChatPreview(), ); }, ); }, ); } } class _ChatPreview extends StatelessWidget { const _ChatPreview(); List<_PreviewMsg> _messagesFor(AppLocalizations l10n) => [ _PreviewMsg(l10n.appearancePreviewHello, true, true, false), _PreviewMsg(l10n.appearancePreviewHowIsIt, true, false, true), _PreviewMsg(l10n.appearancePreviewHello, false, true, false), _PreviewMsg(l10n.appearancePreviewHmm, false, false, false), _PreviewMsg(l10n.appearancePreviewNotBad, false, false, true), ]; BorderRadius _radiusFor( _PreviewMsg msg, BubbleStyle style, BubbleBehavior behavior, ) { return computeBubbleRadius( isMe: msg.isMe, isTop: msg.isTop, isBottom: msg.isBottom, style: style, behavior: behavior, ); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final messages = _messagesFor(l10n); return ListenableBuilder( listenable: Listenable.merge([ AppBubbleShape.current, AppBubbleBehavior.current, ]), builder: (context, _) { final style = AppBubbleShape.current.value; final behavior = AppBubbleBehavior.current.value; return SettingsPanel( color: cs.surfaceContainerLow, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < messages.length; i++) ...[ if (i > 0) SizedBox(height: messages[i].isTop ? 8 : 2), _PreviewBubble( text: messages[i].text, isMe: messages[i].isMe, radius: _radiusFor(messages[i], style, behavior), ), ], ], ), ); }, ); } } class _PreviewMsg { final String text; final bool isMe; final bool isTop; final bool isBottom; const _PreviewMsg(this.text, this.isMe, this.isTop, this.isBottom); } class _PreviewBubble extends StatelessWidget { final String text; final bool isMe; final BorderRadius radius; const _PreviewBubble({ required this.text, required this.isMe, required this.radius, }); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final bg = isMe ? cs.primaryContainer : cs.surfaceContainerHighest; final fg = Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black; return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: Container( constraints: const BoxConstraints(maxWidth: 220), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration(color: bg, borderRadius: radius), child: Text( text, style: TextStyle(color: fg, fontSize: 15, height: 1.3), ), ), ); } } class _ColorPickerCard extends StatelessWidget { final ValueNotifier color; final ValueNotifier isSystem; final bool expanded; final VoidCallback onToggle; final ValueChanged onColorChanged; final VoidCallback onReset; const _ColorPickerCard({ required this.color, required this.isSystem, required this.expanded, required this.onToggle, required this.onColorChanged, required this.onReset, }); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return ValueListenableBuilder( valueListenable: isSystem, builder: (context, sys, _) { return ValueListenableBuilder( valueListenable: color, builder: (context, col, _) => _buildBody(cs, l10n, col, sys), ); }, ); } Widget _buildBody( ColorScheme cs, AppLocalizations l10n, Color col, bool sys, ) { final swatchColor = sys ? cs.primary : col; return SettingsPanel( child: Column( children: [ InkWell( onTap: onToggle, child: Padding( padding: const EdgeInsets.fromLTRB(20, 18, 16, 18), child: Row( children: [ Container( width: 28, height: 28, decoration: BoxDecoration( color: swatchColor, shape: BoxShape.circle, border: Border.all( color: cs.outlineVariant.withValues(alpha: 0.5), ), ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.appearanceAccentColorTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), Text( sys ? l10n.appearanceAccentColorSystem : l10n.appearanceAccentColorSubtitle, style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, ), ), ], ), ), AnimatedRotation( duration: const Duration(milliseconds: 200), turns: expanded ? 0.5 : 0, child: Icon( Symbols.expand_more, color: cs.onSurfaceVariant, size: 24, ), ), ], ), ), ), AnimatedSize( duration: const Duration(milliseconds: 220), curve: Curves.easeInOut, alignment: Alignment.topCenter, child: expanded ? Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _ColorWheelPicker( color: col, onChanged: onColorChanged, ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: FilledButton.tonal( onPressed: sys ? null : onReset, style: FilledButton.styleFrom( shape: AppShape.buttonBorder, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Symbols.auto_awesome, size: 18, weight: 500, ), const SizedBox(width: 8), Text( sys ? l10n.appearanceAccentColorSystemActive : l10n.appearanceAccentColorReset, ), ], ), ), ), ], ), ) : const SizedBox(width: double.infinity), ), ], ), ); } } class _BubbleShapeCard extends StatelessWidget { final ValueChanged onChanged; const _BubbleShapeCard({required this.onChanged}); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return SettingsPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.appearanceBubbleShapeTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), Text( l10n.appearanceBubbleShapeSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 16), ValueListenableBuilder( valueListenable: AppBubbleShape.current, builder: (context, current, _) { return SegmentedButton( segments: [ ButtonSegment( value: BubbleStyle.mobile, label: Text(l10n.appearanceBubbleShapeMobile), icon: const Icon(Symbols.smartphone), ), ButtonSegment( value: BubbleStyle.desktop, label: Text(l10n.appearanceBubbleShapeDesktop), icon: const Icon(Symbols.desktop_windows), ), ], selected: {current}, onSelectionChanged: (set) { if (set.isNotEmpty) onChanged(set.first); }, ); }, ), ], ), ); } } class _BubbleBehaviorCard extends StatelessWidget { final ValueChanged onChanged; const _BubbleBehaviorCard({required this.onChanged}); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; return SettingsPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.appearanceBubbleBehaviorTitle, style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), Text( l10n.appearanceBubbleBehaviorSubtitle, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 16), ValueListenableBuilder( valueListenable: AppBubbleBehavior.current, builder: (context, current, _) { return SegmentedButton( segments: [ ButtonSegment( value: BubbleBehavior.mutable, label: Text(l10n.appearanceBubbleBehaviorMutable), icon: const Icon(Symbols.auto_fix), ), ButtonSegment( value: BubbleBehavior.immutable, label: Text(l10n.appearanceBubbleBehaviorImmutable), icon: const Icon(Symbols.lock), ), ], selected: {current}, onSelectionChanged: (set) { if (set.isNotEmpty) onChanged(set.first); }, ); }, ), ], ), ); } } class _ColorWheelPicker extends StatefulWidget { final Color color; final ValueChanged onChanged; const _ColorWheelPicker({required this.color, required this.onChanged}); @override State<_ColorWheelPicker> createState() => _ColorWheelPickerState(); } class _ColorWheelPickerState extends State<_ColorWheelPicker> { late HSVColor _hsv; late Color _lastEmitted; @override void initState() { super.initState(); _hsv = HSVColor.fromColor(widget.color).withValue(1); _lastEmitted = widget.color; } @override void didUpdateWidget(covariant _ColorWheelPicker oldWidget) { super.didUpdateWidget(oldWidget); if (widget.color != _lastEmitted) { _hsv = HSVColor.fromColor(widget.color).withValue(1); _lastEmitted = widget.color; } } void _handleWheel(Offset local, double size) { final radius = size / 2; final dx = local.dx - radius; final dy = local.dy - radius; final sat = (math.sqrt(dx * dx + dy * dy) / radius).clamp(0.0, 1.0); var hue = math.atan2(dy, dx) * 180 / math.pi; if (hue < 0) hue += 360; final hsv = _hsv.withHue(hue).withSaturation(sat); setState(() => _hsv = hsv); final color = hsv.toColor(); _lastEmitted = color; widget.onChanged(color); } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final wheelSize = math.min(260.0, constraints.maxWidth); return Center( child: GestureDetector( behavior: HitTestBehavior.opaque, onPanDown: (d) => _handleWheel(d.localPosition, wheelSize), onPanUpdate: (d) => _handleWheel(d.localPosition, wheelSize), child: SizedBox( width: wheelSize, height: wheelSize, child: CustomPaint(painter: _WheelPainter(hsv: _hsv)), ), ), ); }, ); } } class _WheelPainter extends CustomPainter { final HSVColor hsv; const _WheelPainter({required this.hsv}); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = size.width / 2; final rect = Rect.fromCircle(center: center, radius: radius); final hueShader = SweepGradient( colors: [ for (var i = 0; i <= 360; i += 30) HSVColor.fromAHSV(1, (i % 360).toDouble(), 1, 1).toColor(), ], stops: [for (var i = 0; i <= 360; i += 30) i / 360], ).createShader(rect); canvas.drawCircle(center, radius, Paint()..shader = hueShader); final satShader = RadialGradient( colors: [Colors.white, Colors.white.withValues(alpha: 0)], ).createShader(rect); canvas.drawCircle(center, radius, Paint()..shader = satShader); final angle = hsv.hue * math.pi / 180; final thumb = Offset( center.dx + hsv.saturation * radius * math.cos(angle), center.dy + hsv.saturation * radius * math.sin(angle), ); canvas.drawShadow( Path()..addOval(Rect.fromCircle(center: thumb, radius: 13)), Colors.black, 2, false, ); canvas.drawCircle(thumb, 13, Paint()..color = Colors.white); canvas.drawCircle(thumb, 10, Paint()..color = hsv.toColor()); } @override bool shouldRepaint(_WheelPainter oldDelegate) => oldDelegate.hsv != hsv; }