Files
Qlyra/lib/frontend/screens/profile/appearance_screen.dart
T

1112 lines
35 KiB
Dart

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<AppearanceScreen> createState() => _AppearanceScreenState();
}
class _AppearanceScreenState extends State<AppearanceScreen> {
static const _fallback = Color(0xFFC1C4FF);
final ValueNotifier<Color> _color = ValueNotifier(_fallback);
final ValueNotifier<bool> _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<VisualStyle>(
valueListenable: AppVisualStyle.current,
builder: (context, current, _) {
final selectable =
current == VisualStyle.liquidGlass && !LiquidGlass.isSupported
? VisualStyle.glossy
: current;
return SegmentedButton<VisualStyle>(
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<ChatChromeStyle>(
valueListenable: AppChatChrome.current,
builder: (context, current, _) {
final selectable =
current == ChatChromeStyle.liquidGlass &&
!LiquidGlass.isSupported
? ChatChromeStyle.transparent
: current;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SegmentedButton<ChatChromeStyle>(
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<ComposerStyle>(
valueListenable: AppComposerStyle.current,
builder: (context, current, _) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SegmentedButton<ComposerStyle>(
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<ComposerBackground>(
valueListenable: AppComposerBackground.current,
builder: (context, current, _) {
final selectable =
current == ComposerBackground.liquidGlass &&
!LiquidGlass.isSupported
? ComposerBackground.frostBlur
: current;
return SegmentedButton<ComposerBackground>(
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<NavPillStyle>(
valueListenable: AppNavPillStyle.current,
builder: (context, current, _) {
final selectable =
current == NavPillStyle.liquidGlass &&
!LiquidGlass.isSupported
? NavPillStyle.frostBlur
: current;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SegmentedButton<NavPillStyle>(
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<bool>(
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<bool>(
valueListenable: AppSpectrumBackground.current,
builder: (context, value, _) => Switch(
value: value,
onChanged: (v) {
Haptics.selection();
AppSpectrumBackground.save(v);
},
),
),
],
),
);
}
}
class _PreviewSection extends StatefulWidget {
final ValueNotifier<Color> color;
final ValueNotifier<bool> 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<bool>(
valueListenable: widget.isSystem,
builder: (context, isSystem, _) {
if (isSystem) {
return Theme(
data: Theme.of(context).copyWith(colorScheme: outerCs),
child: const _ChatPreview(),
);
}
return ValueListenableBuilder<Color>(
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> color;
final ValueNotifier<bool> isSystem;
final bool expanded;
final VoidCallback onToggle;
final ValueChanged<Color> 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<bool>(
valueListenable: isSystem,
builder: (context, sys, _) {
return ValueListenableBuilder<Color>(
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<BubbleStyle> 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<BubbleStyle>(
valueListenable: AppBubbleShape.current,
builder: (context, current, _) {
return SegmentedButton<BubbleStyle>(
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<BubbleBehavior> 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<BubbleBehavior>(
valueListenable: AppBubbleBehavior.current,
builder: (context, current, _) {
return SegmentedButton<BubbleBehavior>(
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<Color> 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;
}