Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/core/theme/querya_theme_scope.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ extension QueryaThemeContext on BuildContext {

QueryaWorkbenchTheme get workbench => queryaTheme.workbench;

/// The workbench tokens of the nearest [QueryaThemeScope], or the defaults
/// for the ambient brightness when there is none (a widget under a bare app,
/// as in tests). Kit controls use this so they never assert on a missing
/// scope.
QueryaWorkbenchTheme get workbenchOrDefault =>
QueryaThemeScope.maybeOf(this)?.workbench ??
(colors.brightness == Brightness.light
? QueryaWorkbenchTheme.lightDefault
: QueryaWorkbenchTheme.darkDefault);

QueryaEditorTheme get editorTheme => queryaTheme.editor;

QueryaSemanticPalette get semanticPalette =>
Expand Down
38 changes: 26 additions & 12 deletions lib/shared/widgets/querya_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,14 @@ enum QueryaButtonTone {
/// keyboard focus.
/// - The label never wraps: it ellipsizes.
class QueryaButton extends material.StatefulWidget {
const QueryaButton._({
/// The general constructor the named ones build on: pick the [variant]
/// yourself (for compositions that take it as a parameter).
const QueryaButton({
super.key,
required this.variant,
required this.label,
required this.icon,
required this.onPressed,
this.label,
this.icon,
this.onPressed,
this.trailing,
this.shortcutHint,
this.loading = false,
Expand All @@ -68,6 +70,7 @@ class QueryaButton extends material.StatefulWidget {
this.semanticLabel,
this.size,
this.tone = QueryaButtonTone.normal,
this.borderRadius,
}) : assert(
label != null || icon != null,
'A QueryaButton needs a label, an icon, or both',
Expand All @@ -87,7 +90,7 @@ class QueryaButton extends material.StatefulWidget {
bool autofocus = false,
String? semanticLabel,
QueryaControlSize? size,
}) : this._(
}) : this(
key: key,
variant: QueryaButtonVariant.primary,
label: label,
Expand Down Expand Up @@ -119,7 +122,7 @@ class QueryaButton extends material.StatefulWidget {
String? semanticLabel,
QueryaControlSize? size,
QueryaButtonTone tone = QueryaButtonTone.normal,
}) : this._(
}) : this(
key: key,
variant: QueryaButtonVariant.secondary,
label: label,
Expand Down Expand Up @@ -153,7 +156,7 @@ class QueryaButton extends material.StatefulWidget {
String? semanticLabel,
QueryaControlSize? size,
QueryaButtonTone tone = QueryaButtonTone.normal,
}) : this._(
}) : this(
key: key,
variant: QueryaButtonVariant.ghost,
label: label,
Expand Down Expand Up @@ -185,7 +188,7 @@ class QueryaButton extends material.StatefulWidget {
bool autofocus = false,
String? semanticLabel,
QueryaControlSize? size,
}) : this._(
}) : this(
key: key,
variant: QueryaButtonVariant.destructive,
label: label,
Expand All @@ -212,7 +215,7 @@ class QueryaButton extends material.StatefulWidget {
bool autofocus = false,
QueryaControlSize? size,
QueryaButtonTone tone = QueryaButtonTone.normal,
}) : this._(
}) : this(
key: key,
variant: QueryaButtonVariant.ghost,
label: null,
Expand Down Expand Up @@ -266,6 +269,10 @@ class QueryaButton extends material.StatefulWidget {
/// See [QueryaButtonTone]; ignored by the filled variants.
final QueryaButtonTone tone;

/// Overrides the corner radius of the control size; a segment of a split
/// button squares its inner corners with it.
final material.BorderRadius? borderRadius;

@override
material.State<QueryaButton> createState() => _QueryaButtonState();
}
Expand Down Expand Up @@ -294,8 +301,8 @@ class _QueryaButtonState extends material.State<QueryaButton> {
@override
material.Widget build(material.BuildContext context) {
final w = widget;
final wb = context.workbench;
final scheme = context.queryaTheme.colorScheme;
final wb = context.workbenchOrDefault;
final scheme = context.colors;
final size = w.size ?? QueryaControlSize.of(context);
final enabled = _enabled;
final hasLabel = w.label != null;
Expand Down Expand Up @@ -407,6 +414,12 @@ class _QueryaButtonState extends material.State<QueryaButton> {
children: children,
);

// A trailing icon takes the button's colour and size.
content = material.IconTheme(
data: material.IconThemeData(color: foreground, size: iconSize),
child: content,
);

// Loading without an icon: keep the label's width, spinner on top.
if (w.loading && w.icon == null) {
content = material.Stack(
Expand All @@ -419,7 +432,8 @@ class _QueryaButtonState extends material.State<QueryaButton> {
}

final height = size.scaledHeight(context);
final radius = material.BorderRadius.circular(size.scaledRadius(context));
final radius = w.borderRadius ??
material.BorderRadius.circular(size.scaledRadius(context));
final ring = scheme.ring;

material.Widget button = material.AnimatedContainer(
Expand Down
154 changes: 154 additions & 0 deletions lib/shared/widgets/querya_dialog_actions.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart' as material;
import 'package:flutter/services.dart' show LogicalKeyboardKey;
import 'package:querya_desktop/core/layout/ui_scale.dart';
import 'package:querya_desktop/core/theme/querya_theme_scope.dart';
import 'package:querya_desktop/core/ui/querya_control_tokens.dart';
import 'package:querya_desktop/shared/widgets/querya_button.dart';

/// The footer row of a dialog (docs/ui-kit.md, "Dialog footers"; #1335):
///
/// ```
/// [ leading ] [ Cancel ] [ Secondary… ] [ Primary ]
/// ```
///
/// - Everything is `lg`, 8 apart, right-aligned, under a divider.
/// - **One** main action: [primary] or [destructive], never both; in debug the
/// variants of every slot are checked (cancel is ghost, secondary slots are
/// secondary, the main one is primary / destructive).
/// - `Esc` runs [cancel] while focus is in the footer. For `Esc` and `Enter`
/// across the whole dialog wrap its body in a `CallbackShortcuts` with
/// [QueryaDialogActions.bindings].
class QueryaDialogActions extends material.StatelessWidget {
const QueryaDialogActions({
super.key,
this.cancel,
this.secondary = const [],
this.primary,
this.destructive,
this.leading,
this.showDivider = true,
}) : assert(
primary == null || destructive == null,
'A dialog footer has one main action: primary or destructive, not both',
);

/// Cancel / Close: a [QueryaButtonVariant.ghost] button, placed first.
final QueryaButton? cancel;

/// Other actions (Test connection, Save as draft): `secondary` buttons.
final List<QueryaButton> secondary;

/// The main action: a [QueryaButtonVariant.primary] button.
final QueryaButton? primary;

/// The main action when it is irreversible: a
/// [QueryaButtonVariant.destructive] button.
final QueryaButton? destructive;

/// Something left-aligned in the footer (a status, a checkbox).
final material.Widget? leading;
final bool showDivider;

QueryaButton? get _main => primary ?? destructive;

/// `Esc` -> cancel and `Enter` -> the main action, for the dialog's
/// `CallbackShortcuts`. A disabled button is not run.
static Map<material.ShortcutActivator, material.VoidCallback> bindings({
QueryaButton? cancel,
QueryaButton? primary,
QueryaButton? destructive,
}) {
final main = primary ?? destructive;
return {
if (cancel?.onPressed != null)
const material.SingleActivator(LogicalKeyboardKey.escape):
cancel!.onPressed!,
if (main?.onPressed != null && main?.loading != true)
const material.SingleActivator(LogicalKeyboardKey.enter):
main!.onPressed!,
};
}

@override
material.Widget build(material.BuildContext context) {
assert(() {
final c = cancel;
if (c != null && c.variant != QueryaButtonVariant.ghost) {
throw material.FlutterError(
'QueryaDialogActions.cancel must be a ghost button, got '
'${c.variant.name}.',
);
}
for (final s in secondary) {
if (s.variant != QueryaButtonVariant.secondary) {
throw material.FlutterError(
'QueryaDialogActions.secondary buttons must be secondary, got '
'${s.variant.name}.',
);
}
}
final p = primary;
if (p != null && p.variant != QueryaButtonVariant.primary) {
throw material.FlutterError(
'QueryaDialogActions.primary must be a primary button, got '
'${p.variant.name}.',
);
}
final d = destructive;
if (d != null && d.variant != QueryaButtonVariant.destructive) {
throw material.FlutterError(
'QueryaDialogActions.destructive must be a destructive button, got '
'${d.variant.name}.',
);
}
return true;
}());

final wb = context.workbenchOrDefault;
final gap = context.scaled(8);
final buttons = <material.Widget>[
if (cancel != null) cancel!,
...secondary,
if (_main != null) _main!,
];

final row = material.Row(
children: [
if (leading != null) material.Flexible(child: leading!),
const material.Spacer(),
for (var i = 0; i < buttons.length; i++) ...[
if (i > 0) material.SizedBox(width: gap),
buttons[i],
],
],
);

final footer = material.CallbackShortcuts(
bindings: {
if (cancel?.onPressed != null)
const material.SingleActivator(LogicalKeyboardKey.escape):
cancel!.onPressed!,
},
child: QueryaControlScope(
size: QueryaControlSize.lg,
child: material.Padding(
padding: const material.EdgeInsets.fromLTRB(16, 12, 16, 12),
child: row,
),
),
);

if (!showDivider) return footer;
return material.Column(
mainAxisSize: material.MainAxisSize.min,
crossAxisAlignment: material.CrossAxisAlignment.stretch,
children: [
material.SizedBox(
height: 1,
child: material.ColoredBox(color: wb.borderSubtle),
),
footer,
],
);
}
}
82 changes: 82 additions & 0 deletions lib/shared/widgets/querya_menu_button.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/core/ui/querya_control_tokens.dart';
import 'package:querya_desktop/shared/widgets/querya_action_menu.dart'
show QueryaActionMenuItem;
import 'package:querya_desktop/shared/widgets/querya_button.dart';
import 'package:querya_desktop/shared/widgets/querya_menu_surface.dart';

/// A button that opens a menu of commands (#1335): a [QueryaButton] with a
/// trailing chevron, shown as active while the menu is open. The menu surface
/// is the one `QueryaDropdown` uses.
///
/// It replaces the trigger of `QueryaActionMenu` / `ExportMenuButton`, so a
/// menu next to a button in a toolbar has the same height and states.
class QueryaMenuButton<T> extends material.StatelessWidget {
const QueryaMenuButton({
super.key,
required this.items,
required this.onSelected,
this.label,
this.icon,
this.variant = QueryaButtonVariant.secondary,
this.tone = QueryaButtonTone.normal,
this.size,
this.tooltip,
this.compact = false,
this.enabled = true,
this.menuController,
}) : assert(
label != null || icon != null,
'A QueryaMenuButton needs a label, an icon, or both',
);

final List<QueryaActionMenuItem<T>> items;
final material.ValueChanged<T> onSelected;

/// The text; null for an icon-only trigger.
final String? label;
final material.IconData? icon;

/// [QueryaButtonVariant.secondary] by default; [QueryaButtonVariant.ghost]
/// for toolbars.
final QueryaButtonVariant variant;
final QueryaButtonTone tone;
final QueryaControlSize? size;
final String? tooltip;

/// Icon only on narrow toolbars; the label becomes the tooltip.
final bool compact;
final bool enabled;

/// Optional external controller, to open or close the menu from code.
final material.MenuController? menuController;

@override
material.Widget build(material.BuildContext context) {
final controlSize = size ?? QueryaControlSize.of(context);
return material.MenuAnchor(
controller: menuController,
alignmentOffset: queryaMenuAlignmentOffset(context),
consumeOutsideTap: true,
style: queryaMenuStyle(context),
menuChildren: queryaMenuItems<T>(context, items, onSelected),
builder: (context, controller, _) => QueryaButton(
variant: variant,
tone: tone,
label: label,
icon: icon,
size: size,
tooltip: tooltip,
compact: compact,
isActive: controller.isOpen,
trailing: material.Icon(
material.Icons.keyboard_arrow_down_rounded,
size: controlSize.scaledIconSize(context),
),
onPressed: enabled
? () => controller.isOpen ? controller.close() : controller.open()
: null,
),
);
}
}
Loading
Loading