diff --git a/lib/features/extensions/extension_stats_view.dart b/lib/features/extensions/extension_stats_view.dart index 409619e8..96d525ee 100644 --- a/lib/features/extensions/extension_stats_view.dart +++ b/lib/features/extensions/extension_stats_view.dart @@ -314,7 +314,7 @@ class _ExtensionStatsViewState extends material.State material.Widget _buildChip( QueryaUiColors cs, ({String label, String value, material.IconData icon}) chip) { - return Card( + return QueryaCard( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: material.Row( children: [ @@ -361,7 +361,7 @@ class _ExtensionStatsViewState extends material.State final sorted = sizes.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); - return Card( + return QueryaCard( padding: const material.EdgeInsets.all(20), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, @@ -459,7 +459,7 @@ class _ExtensionStatsViewState extends material.State final cs = context.colors; final extra = stats.extraMetrics.entries.toList(); - return Card( + return QueryaCard( padding: const material.EdgeInsets.all(20), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, diff --git a/lib/features/mongodb/mongo_aggregation_workspace.dart b/lib/features/mongodb/mongo_aggregation_workspace.dart index ffbdbd4f..3d2b4271 100644 --- a/lib/features/mongodb/mongo_aggregation_workspace.dart +++ b/lib/features/mongodb/mongo_aggregation_workspace.dart @@ -569,7 +569,7 @@ class _StageCardState extends material.State<_StageCard> { final stage = widget.stage; final syntaxError = validateStage(stage); - return Card( + return QueryaCard( child: material.Padding( padding: const material.EdgeInsets.all(12), child: material.Column( diff --git a/lib/features/mongodb/mongo_indexes_view.dart b/lib/features/mongodb/mongo_indexes_view.dart index b55f60fe..37187808 100644 --- a/lib/features/mongodb/mongo_indexes_view.dart +++ b/lib/features/mongodb/mongo_indexes_view.dart @@ -339,7 +339,7 @@ class _IndexCard extends material.StatelessWidget { material.Widget build(material.BuildContext context) { final cs = context.colors; - return Card( + return QueryaCard( child: material.Padding( padding: const material.EdgeInsets.all(14), child: material.Row( diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index 1efefdb1..924e74e3 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -298,34 +298,36 @@ class _InterfaceScaleSliderState extends material.State { return material.Row( children: [ material.Expanded( - child: Slider( - value: SliderValue.single( - _sliderPosition(displayScale, fine: fine), + child: material.SliderTheme( + data: material.SliderTheme.of(context).copyWith( + activeTrackColor: cs.primary, + inactiveTrackColor: cs.muted, + thumbColor: cs.primary, + overlayColor: cs.primary.withValues(alpha: 0.12), + activeTickMarkColor: cs.primaryForeground.withValues(alpha: 0.5), + inactiveTickMarkColor: cs.mutedForeground.withValues(alpha: 0.5), + trackHeight: 4, + ), + child: material.Slider( + value: _sliderPosition(displayScale, fine: fine), + min: fine ? kMinUiScale : 0, + max: fine ? kMaxUiScale : (kUiScalePresets.length - 1).toDouble(), + divisions: fine ? _fineDivisions : kUiScalePresets.length - 1, + onChanged: (value) { + final next = _scaleFromSlider(value, fine: _shiftHeld); + setState(() => _dragScale = next); + }, + onChangeEnd: (value) { + final next = _scaleFromSlider(value, fine: _shiftHeld); + setState(() => _dragScale = null); + unawaited( + UiScaleController.instance.commitScale( + next, + fine: _shiftHeld, + ), + ); + }, ), - min: fine ? kMinUiScale : 0, - max: fine ? kMaxUiScale : (kUiScalePresets.length - 1).toDouble(), - divisions: fine ? _fineDivisions : kUiScalePresets.length - 1, - hintValue: const SliderValue.single(kDefaultUiScale), - onChanged: (value) { - final next = _scaleFromSlider( - value.value, - fine: _shiftHeld, - ); - setState(() => _dragScale = next); - }, - onChangeEnd: (value) { - final next = _scaleFromSlider( - value.value, - fine: _shiftHeld, - ); - setState(() => _dragScale = null); - unawaited( - UiScaleController.instance.commitScale( - next, - fine: _shiftHeld, - ), - ); - }, ), ), const material.SizedBox(width: 8), diff --git a/lib/features/settings/theme_color_picker_dialog.dart b/lib/features/settings/theme_color_picker_dialog.dart index 553c9d43..af3b958b 100644 --- a/lib/features/settings/theme_color_picker_dialog.dart +++ b/lib/features/settings/theme_color_picker_dialog.dart @@ -7,7 +7,7 @@ Future showThemeColorPickerDialog({ required material.BuildContext context, required Color initial, }) async { - var picked = ColorDerivative.fromColor(initial); + var picked = initial; return showAppDialog( context: context, @@ -16,8 +16,8 @@ Future showThemeColorPickerDialog({ title: const material.Text('Pick color'), content: material.SizedBox( width: 320, - height: 360, - child: ColorPicker( + height: 300, + child: QueryaColorPicker( value: picked, onChanged: (value) => picked = value, ), @@ -31,7 +31,7 @@ Future showThemeColorPickerDialog({ QueryaButton.primary( label: 'Apply', onPressed: () => - material.Navigator.pop(dialogContext, picked.toColor()), + material.Navigator.pop(dialogContext, picked), size: QueryaControlSize.lg, ), ], diff --git a/lib/shared/widgets/querya_card.dart b/lib/shared/widgets/querya_card.dart new file mode 100644 index 00000000..2d38b410 --- /dev/null +++ b/lib/shared/widgets/querya_card.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; + +/// A raised surface: the card colour, a hairline border, rounded corners +/// (#1734, epic #1465). Replaces shadcn's `Card`. +class QueryaCard extends material.StatelessWidget { + const QueryaCard({ + super.key, + required this.child, + this.padding, + this.radius = 10, + }); + + final material.Widget child; + + /// Inside the border; none by default (the child pads itself). + final material.EdgeInsetsGeometry? padding; + final double radius; + + @override + material.Widget build(material.BuildContext context) { + final cs = context.colors; + return material.Container( + padding: padding, + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: cs.border.withValues(alpha: 0.7)), + ), + child: child, + ); + } +} diff --git a/lib/shared/widgets/querya_color_picker.dart b/lib/shared/widgets/querya_color_picker.dart new file mode 100644 index 00000000..50456a07 --- /dev/null +++ b/lib/shared/widgets/querya_color_picker.dart @@ -0,0 +1,298 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/shared/widgets/querya_text_field.dart'; + +/// A colour picker (#1734, epic #1465): a saturation / brightness area, hue +/// and alpha bars, and a hex field (`#RRGGBB` or `#AARRGGBB`). +class QueryaColorPicker extends material.StatefulWidget { + const QueryaColorPicker({ + super.key, + required this.value, + required this.onChanged, + this.alpha = true, + }); + + final material.Color value; + final material.ValueChanged onChanged; + + /// Whether the alpha bar and the `AA` of the hex are offered. + final bool alpha; + + /// `#RRGGBB`, or `#AARRGGBB` with [alpha]. + static String toHex(material.Color c, {bool alpha = true}) { + String h(double v) => + (v * 255).round().clamp(0, 255).toRadixString(16).padLeft(2, '0'); + return '#${alpha ? h(c.a) : ''}${h(c.r)}${h(c.g)}${h(c.b)}'.toUpperCase(); + } + + /// The colour of `RRGGBB`, `#RRGGBB`, `AARRGGBB` or `#AARRGGBB`; null when + /// it is not one of them. + static material.Color? parseHex(String text) { + final t = text.trim().replaceFirst('#', ''); + if (!RegExp(r'^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$').hasMatch(t)) return null; + final v = int.parse(t, radix: 16); + return material.Color(t.length == 6 ? 0xFF000000 | v : v); + } + + @override + material.State createState() => _QueryaColorPickerState(); +} + +class _QueryaColorPickerState extends material.State { + late material.HSVColor _hsv; + late final material.TextEditingController _hex; + + @override + void initState() { + super.initState(); + _hsv = material.HSVColor.fromColor(widget.value); + _hex = material.TextEditingController( + text: QueryaColorPicker.toHex(widget.value, alpha: widget.alpha)); + } + + @override + void dispose() { + _hex.dispose(); + super.dispose(); + } + + void _set(material.HSVColor next) { + setState(() => _hsv = next); + final c = next.toColor(); + _hex.text = QueryaColorPicker.toHex(c, alpha: widget.alpha); + widget.onChanged(c); + } + + void _typed(String text) { + final c = QueryaColorPicker.parseHex(text); + if (c == null) return; + setState(() => _hsv = material.HSVColor.fromColor(c)); + widget.onChanged(c); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = context.colors; + final hueColor = material.HSVColor.fromAHSV(1, _hsv.hue, 1, 1).toColor(); + final opaque = _hsv.withAlpha(1).toColor(); + return material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.SizedBox( + height: 160, + child: _Drag( + onPosition: (p) => _set(_hsv + .withSaturation(p.dx.clamp(0, 1)) + .withValue(1 - p.dy.clamp(0, 1))), + child: material.CustomPaint( + painter: _SvPainter(hueColor, _hsv.saturation, _hsv.value, + cs.foreground), + ), + ), + ), + const material.SizedBox(height: 12), + _Bar( + height: 14, + position: _hsv.hue / 360, + onPosition: (v) => _set(_hsv.withHue((v * 360).clamp(0, 359.999))), + gradient: material.LinearGradient(colors: [ + for (var i = 0; i <= 6; i++) + material.HSVColor.fromAHSV(1, i * 60, 1, 1).toColor(), + ]), + thumb: hueColor, + ), + if (widget.alpha) ...[ + const material.SizedBox(height: 10), + _Bar( + height: 14, + position: _hsv.alpha, + onPosition: (v) => _set(_hsv.withAlpha(v.clamp(0, 1))), + gradient: material.LinearGradient( + colors: [opaque.withValues(alpha: 0), opaque]), + thumb: _hsv.toColor(), + checkered: true, + ), + ], + const material.SizedBox(height: 12), + material.Row( + children: [ + material.Container( + width: 32, + height: 32, + decoration: material.BoxDecoration( + color: _hsv.toColor(), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all(color: cs.border), + ), + ), + const material.SizedBox(width: 10), + material.Expanded( + child: QueryaTextField( + controller: _hex, + onChanged: _typed, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[#0-9a-fA-F]')), + LengthLimitingTextInputFormatter(9), + ], + ), + ), + ], + ), + ], + ); + } +} + +/// Reports the pointer as a 0–1 position in its box while pressed or dragged. +class _Drag extends material.StatelessWidget { + const _Drag({required this.child, required this.onPosition}); + + final material.Widget child; + final material.ValueChanged onPosition; + + @override + material.Widget build(material.BuildContext context) { + return material.LayoutBuilder(builder: (context, box) { + void at(material.Offset local) => onPosition(material.Offset( + (local.dx / box.maxWidth).clamp(0, 1), + (local.dy / box.maxHeight).clamp(0, 1), + )); + return material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onPanDown: (d) => at(d.localPosition), + onPanUpdate: (d) => at(d.localPosition), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(6), + child: child, + ), + ); + }); + } +} + +class _Bar extends material.StatelessWidget { + const _Bar({ + required this.height, + required this.position, + required this.onPosition, + required this.gradient, + required this.thumb, + this.checkered = false, + }); + + final double height; + final double position; + final material.ValueChanged onPosition; + final material.Gradient gradient; + final material.Color thumb; + final bool checkered; + + @override + material.Widget build(material.BuildContext context) { + final cs = context.colors; + return material.SizedBox( + height: height + 8, + child: material.LayoutBuilder(builder: (context, box) { + final x = position.clamp(0, 1) * box.maxWidth; + return material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onPanDown: (d) => onPosition(d.localPosition.dx / box.maxWidth), + onPanUpdate: (d) => onPosition(d.localPosition.dx / box.maxWidth), + child: material.Stack( + clipBehavior: material.Clip.none, + alignment: material.Alignment.centerLeft, + children: [ + material.Positioned.fill( + top: 4, + bottom: 4, + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(height / 2), + child: material.Stack(fit: material.StackFit.expand, children: [ + if (checkered) + material.CustomPaint(painter: _Checkers(cs.border)), + material.DecoratedBox( + decoration: material.BoxDecoration(gradient: gradient)), + ]), + ), + ), + material.Positioned( + left: x - 9, + child: material.Container( + width: 18, + height: 18, + decoration: material.BoxDecoration( + color: thumb, + shape: material.BoxShape.circle, + border: material.Border.all(color: cs.foreground, width: 2), + ), + ), + ), + ], + ), + ); + }), + ); + } +} + +class _SvPainter extends material.CustomPainter { + _SvPainter(this.hue, this.s, this.v, this.ring); + + final material.Color hue; + final double s; + final double v; + final material.Color ring; + + @override + void paint(material.Canvas canvas, material.Size size) { + final rect = material.Offset.zero & size; + canvas.drawRect( + rect, + material.Paint() + ..shader = material.LinearGradient( + colors: [material.Colors.white, hue], + ).createShader(rect)); + canvas.drawRect( + rect, + material.Paint() + ..shader = const material.LinearGradient( + begin: material.Alignment.topCenter, + end: material.Alignment.bottomCenter, + colors: [material.Color(0x00000000), material.Color(0xFF000000)], + ).createShader(rect)); + final p = material.Offset(s * size.width, (1 - v) * size.height); + canvas.drawCircle( + p, + 8, + material.Paint() + ..style = material.PaintingStyle.stroke + ..strokeWidth = 2 + ..color = ring); + } + + @override + bool shouldRepaint(_SvPainter old) => + old.hue != hue || old.s != s || old.v != v || old.ring != ring; +} + +class _Checkers extends material.CustomPainter { + _Checkers(this.color); + + final material.Color color; + + @override + void paint(material.Canvas canvas, material.Size size) { + final paint = material.Paint()..color = color.withValues(alpha: 0.5); + const cell = 7.0; + for (var y = 0.0, row = 0; y < size.height; y += cell, row++) { + for (var x = row.isEven ? 0.0 : cell; x < size.width; x += cell * 2) { + canvas.drawRect(material.Rect.fromLTWH(x, y, cell, cell), paint); + } + } + } + + @override + bool shouldRepaint(_Checkers old) => old.color != color; +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 7b9b944f..6d4a609c 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -38,6 +38,8 @@ export 'querya_search_field.dart'; export 'querya_spinner.dart'; export 'querya_text.dart'; export 'querya_text_field.dart'; +export 'querya_card.dart'; +export 'querya_color_picker.dart'; export 'tree_load_error.dart'; export 'package:querya_desktop/core/motion/querya_motion.dart'; export 'package:querya_desktop/core/motion/querya_motion_context.dart'; diff --git a/test/features/settings/interface_scale_slider_test.dart b/test/features/settings/interface_scale_slider_test.dart index ee651abe..b66140ac 100644 --- a/test/features/settings/interface_scale_slider_test.dart +++ b/test/features/settings/interface_scale_slider_test.dart @@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart' hide TextExtension; import '../../support/querya_theme_test_shell.dart'; @@ -20,7 +19,7 @@ void main() { await tester.pump(); expect(find.text('100%'), findsOneWidget); - expect(find.byType(Slider), findsOneWidget); + expect(find.byType(material.Slider), findsOneWidget); }); test('UiScaleController.normalize snaps to presets unless fine mode', () { diff --git a/test/shared/widgets/querya_color_picker_test.dart b/test/shared/widgets/querya_color_picker_test.dart new file mode 100644 index 00000000..7dc7df1d --- /dev/null +++ b/test/shared/widgets/querya_color_picker_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_color_picker.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + test('hex round trip, with and without alpha', () { + const c = material.Color(0x80FF8800); + expect(QueryaColorPicker.toHex(c), '#80FF8800'); + expect(QueryaColorPicker.toHex(c, alpha: false), '#FF8800'); + expect(QueryaColorPicker.parseHex('#80ff8800'), c); + expect(QueryaColorPicker.parseHex('ff8800'), const material.Color(0xFFFF8800)); + expect(QueryaColorPicker.parseHex('#ff88'), isNull); + expect(QueryaColorPicker.parseHex('nothex'), isNull); + }); + + testWidgets('typing a hex reports that colour', (t) async { + material.Color? seen; + await t.pumpWidget(queryaThemeTestShell( + child: material.Material( + child: QueryaColorPicker( + value: const material.Color(0xFF000000), + onChanged: (c) => seen = c, + ), + ), + )); + await t.enterText(find.byType(material.TextField), '#FF8800'); + expect(seen, const material.Color(0xFFFF8800)); + }); +}