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
94 changes: 71 additions & 23 deletions lib/features/erd/erd_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,14 @@ class _ErdViewState extends material.State<ErdView> {
return material.Size(w, h);
}

/// Tint of a note without a colour over the canvas, on screen and in the
/// exports.
static const double _noteTint = 0.07;

/// Shadows read only on light surfaces, so a dark canvas gets twice the alpha.
double _shadowAlpha(material.Color canvas) =>
canvas.computeLuminance() < 0.5 ? 0.24 : 0.12;

material.Widget _noteWidget(ErdNote n) {
final wb = context.workbench;
final color = n.color == null ? null : _slotColor(n.color!);
Expand Down Expand Up @@ -798,7 +806,12 @@ class _ErdViewState extends material.State<ErdView> {
child: material.GestureDetector(
key: material.ValueKey('erd_note_${n.id}'),
dragStartBehavior: DragStartBehavior.down,
onPanStart: (_) => setState(() => _draggingNote = n.id),
// Esc, zoom and search keys work after touching a note.
onTapDown: (_) => _canvasFocus.requestFocus(),
onPanStart: (_) {
_canvasFocus.requestFocus();
setState(() => _draggingNote = n.id);
},
onPanUpdate: (d) => _noteDragMove(n.id, d.delta),
onPanEnd: (_) {
setState(() => _draggingNote = null);
Expand All @@ -808,14 +821,25 @@ class _ErdViewState extends material.State<ErdView> {
onDoubleTap: () => unawaited(_editNote(n)),
child: material.DecoratedBox(
decoration: material.BoxDecoration(
color: color == null
? wb.surface
: material.Color.alphaBlend(
color.withValues(alpha: 0.18), wb.surface),
// Not the canvas colour: a plain note is a slightly
// darker sheet with a shadow, like the cards.
color: material.Color.alphaBlend(
(color ?? wb.mutedForeground).withValues(
alpha: color == null ? _noteTint : 0.18),
wb.surface),
borderRadius: material.BorderRadius.circular(6),
border: material.Border.all(
color: color ?? wb.borderSubtle,
width: n.attachedTo == null ? 1 : 1.5),
boxShadow: [
material.BoxShadow(
color: wb.shadow
.withValues(alpha: _shadowAlpha(wb.canvas)),
blurRadius: _draggingNote == n.id ? 14 : 6,
offset: material.Offset(
0, _draggingNote == n.id ? 5 : 2),
),
],
),
child: material.ClipRect(
child: material.Padding(
Expand Down Expand Up @@ -863,6 +887,7 @@ class _ErdViewState extends material.State<ErdView> {
child: material.GestureDetector(
key: material.ValueKey('erd_note_resize_${n.id}'),
dragStartBehavior: DragStartBehavior.down,
onPanStart: (_) => _canvasFocus.requestFocus(),
onPanUpdate: (d) => _noteResize(n.id, d.delta),
onPanEnd: (_) => _scheduleSave(),
child: material.Icon(material.Icons.drag_handle_rounded,
Expand Down Expand Up @@ -1412,8 +1437,7 @@ class _ErdViewState extends material.State<ErdView> {
routes: _routes,
headerFills: _headerFills(schema, const ErdSvgColors().card),
groups: _svgGroups(layout, const ErdSvgColors().background),
notes: _svgNotes(const ErdSvgColors().background,
const ErdSvgColors().border));
notes: _svgNotes(const ErdSvgColors()));
await _save('$_fileStem.png', await svgToPng(svg, scale: scale));
return;
}
Expand Down Expand Up @@ -1493,7 +1517,7 @@ class _ErdViewState extends material.State<ErdView> {
colors: colors,
headerFills: _headerFills(schema, colors.card),
groups: _svgGroups(layout, colors.background),
notes: _svgNotes(colors.background, colors.border));
notes: _svgNotes(colors));
}

/// The SVG export.
Expand Down Expand Up @@ -1596,23 +1620,32 @@ class _ErdViewState extends material.State<ErdView> {
};
}

/// [name] cut to 28 characters with an ellipsis, for menus and the toolbar.
static String _shortName(String name) =>
name.length <= 28 ? name : '${name.substring(0, 27)}…';

/// The toolbar's view switcher: All tables, the saved views, and the
/// commands to save, rename and delete one.
material.Widget _viewsMenu() {
final wb = context.workbench;
final active = _viewById(_activeView);
return QueryaActionMenu<String>(
items: [
// A mark on every row, so the labels line up.
QueryaActionMenuItem(
value: 'all',
label: 'All tables',
icon: active == null ? material.Icons.check : null,
icon: active == null
? material.Icons.radio_button_checked_rounded
: material.Icons.radio_button_unchecked_rounded,
),
for (final v in _views)
QueryaActionMenuItem(
value: 'view:${v.id}',
label: '${v.name} (${v.tables.length})',
icon: v.id == active?.id ? material.Icons.check : null,
label: '${_shortName(v.name)} (${v.tables.length})',
icon: v.id == active?.id
? material.Icons.radio_button_checked_rounded
: material.Icons.radio_button_unchecked_rounded,
),
const QueryaActionMenuItem(
value: 'new',
Expand Down Expand Up @@ -1655,7 +1688,7 @@ class _ErdViewState extends material.State<ErdView> {
material.Icon(material.Icons.bookmarks_outlined,
size: 16, color: wb.mutedForeground),
const material.SizedBox(width: 6),
Text(active?.name ?? 'All tables'),
Text(active == null ? 'All tables' : _shortName(active.name)),
const material.SizedBox(width: 4),
material.Icon(material.Icons.expand_more_rounded,
size: 16, color: wb.mutedForeground),
Expand All @@ -1674,6 +1707,12 @@ class _ErdViewState extends material.State<ErdView> {
MenuButton(
key: material.ValueKey('erd_menu_group_$table'),
onPressed: (_) => unawaited(_newGroup(tables)),
// The way to put more tables in is not obvious: say it here.
trailing: tables.length == 1
? Text('Shift+click marks more',
style: material.TextStyle(
fontSize: 11, color: context.workbench.mutedForeground))
: null,
child: Text(tables.length == 1
? 'New group…'
: 'Group ${tables.length} tables…'),
Expand All @@ -1700,22 +1739,23 @@ class _ErdViewState extends material.State<ErdView> {
}

/// Notes for the SVG, tinted as on screen over the export background.
List<ErdSvgNote> _svgNotes(String backgroundHex, String borderHex) {
final background = material.Color(
0xFF000000 | int.parse(backgroundHex.substring(1), radix: 16));
List<ErdSvgNote> _svgNotes(ErdSvgColors colors) {
material.Color parse(String hex) =>
material.Color(0xFF000000 | int.parse(hex.substring(1), radix: 16));
final background = parse(colors.background);
final muted = parse(colors.muted);
return [
for (final n in _notes)
ErdSvgNote(
text: n.text,
rect: material.Rect.fromLTWH(n.x, n.y, n.width, n.height),
fill: ErdSvgColors.hex((n.color == null
? background
: material.Color.alphaBlend(
_slotColor(n.color!).withValues(alpha: 0.18),
background))
fill: ErdSvgColors.hex(material.Color.alphaBlend(
(n.color == null ? muted : _slotColor(n.color!))
.withValues(alpha: n.color == null ? _noteTint : 0.18),
background)
.toARGB32()),
stroke: n.color == null
? borderHex
? colors.border
: ErdSvgColors.hex(_slotColor(n.color!).toARGB32()),
),
];
Expand Down Expand Up @@ -1752,7 +1792,11 @@ class _ErdViewState extends material.State<ErdView> {
child: material.GestureDetector(
key: material.ValueKey('erd_group_${g.id}'),
dragStartBehavior: DragStartBehavior.down,
onPanStart: (_) => _groupDragStart(g.id),
onTapDown: (_) => _canvasFocus.requestFocus(),
onPanStart: (_) {
_canvasFocus.requestFocus();
_groupDragStart(g.id);
},
onPanUpdate: (d) => _groupDragMove(g, d.delta),
onPanEnd: (_) => _groupDragEnd(),
onPanCancel: _groupDragEnd,
Expand Down Expand Up @@ -2555,9 +2599,13 @@ class _TableCard extends material.StatelessWidget {
borderRadius: radius,
border: material.Border.all(
color: highlighted || marked ? wb.accent : wb.borderSubtle,
width: marked ? 2.5 : (highlighted ? 1.5 : 1),
width: highlighted ? 1.5 : 1,
),
boxShadow: [
// A marked card gets a ring outside it, so its content area
// is the one the card was measured for.
if (marked)
material.BoxShadow(color: wb.accent, spreadRadius: 2.5),
material.BoxShadow(
color: wb.shadow.withValues(alpha: _shadowAlpha(wb.canvas)),
blurRadius: dragging ? 18 : 8,
Expand Down
176 changes: 176 additions & 0 deletions test/features/erd/erd_ui_review_polish_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import 'package:flutter/gestures.dart' show kSecondaryButton;
import 'package:flutter/material.dart' as material;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/core/database/table_mutation_engine.dart';
import 'package:querya_desktop/core/erd/erd_catalog.dart';
import 'package:querya_desktop/core/erd/erd_saved_layout.dart';
import 'package:querya_desktop/core/theme/querya_theme_scope.dart';
import 'package:querya_desktop/features/erd/erd_source.dart';
import 'package:querya_desktop/features/erd/erd_view.dart';
import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart';

import '../../support/fake_sql_execution_delegate.dart';
import '../../support/querya_theme_test_shell.dart';

/// Polish from the ER diagram interface review: the plain note, keys after a
/// note was touched, the views menu, the grouping hint, the mark on a card.
class _MemoryStore implements ErdLayoutStore {
_MemoryStore([this.initial]);

final ErdSavedLayout? initial;
final layouts = <ErdLayoutKey, ErdSavedLayout>{};

@override
Future<ErdSavedLayout?> read(ErdLayoutKey key) async =>
layouts[key] ?? initial;

@override
Future<void> write(ErdLayoutKey key, ErdSavedLayout layout) async =>
layouts[key] = layout;
}

const _key = ErdLayoutKey(connectionId: 1, scope: 'shop');

FakeSqlExecutionDelegate _catalog() =>
FakeSqlExecutionDelegate(onExecute: (sql) {
if (sql == ErdCatalog.columnsSql(SqlDialect.sqlite)) {
return const SqlExecutionResult(rows: [
['users', 'id', 'INTEGER', '1'],
['orders', 'user_id', 'INTEGER', '0'],
]);
}
return const SqlExecutionResult(rows: [
['orders', 'user_id', 'users', 'id'],
]);
});

final _note = find.byKey(const material.ValueKey('erd_note_n1'));
const _plainNote = ErdSavedLayout(notes: [
ErdNote(id: 'n1', text: 'Plain', x: 40, y: 300),
]);

void main() {
final desktop = TargetPlatformVariant.only(material.TargetPlatform.linux);

Future<void> pumpDiagram(
WidgetTester t,
_MemoryStore store, {
material.Widget? above,
}) async {
await t.binding.setSurfaceSize(const material.Size(1200, 800));
addTearDown(() => t.binding.setSurfaceSize(null));
final view = ErdView(
source: SqlErdSource(delegate: _catalog(), dialect: SqlDialect.sqlite),
layoutStore: store,
layoutKey: _key,
);
await t.pumpWidget(queryaThemeTestShell(
child: above == null
? view
: material.Column(children: [above, material.Expanded(child: view)]),
));
await t.pump();
await t.pump();
await t.pump();
}

testWidgets('a plain note is not the canvas colour and has a shadow',
(t) async {
await pumpDiagram(t, _MemoryStore(_plainNote));
final box = t.widget<material.DecoratedBox>(find
.descendant(of: _note, matching: find.byType(material.DecoratedBox))
.first);
final decoration = box.decoration as material.BoxDecoration;
final surface = t.element(_note).workbench.surface;
expect(decoration.color, isNot(surface));
expect(decoration.boxShadow, isNotEmpty);
});

testWidgets('keys work after a note was touched while focus was elsewhere',
(t) async {
final outside = material.FocusNode();
addTearDown(outside.dispose);
await pumpDiagram(
t,
_MemoryStore(_plainNote),
above: material.Focus(
focusNode: outside, child: const material.SizedBox(height: 4)),
);
String zoom() => t
.widget<material.Text>(
find.byKey(const material.ValueKey('erd_zoom_label')))
.data!;
final fitted = zoom();
await t.tap(find.byKey(const material.ValueKey('erd_zoom_in')));
await t.pump();
expect(zoom(), isNot(fitted));

// The keyboard is somewhere else (another pane, a closed dialog).
outside.requestFocus();
await t.pump();
expect(outside.hasPrimaryFocus, isTrue);
await t.tap(_note);
await t.pump(const Duration(milliseconds: 400));
expect(outside.hasPrimaryFocus, isFalse);

await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await t.sendKeyEvent(LogicalKeyboardKey.digit0);
await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await t.pump();
expect(zoom(), fitted);
});

testWidgets('a long view name is cut in the toolbar', variant: desktop,
(t) async {
await pumpDiagram(t, _MemoryStore());
await t.tap(find.byKey(const material.ValueKey('erd_views')));
await t.pumpAndSettle();
await t.tap(find.text('Save as new view…'));
await t.pump();
await t.pump(const Duration(milliseconds: 400));
const long = 'Billing and invoicing, payments and refunds by month';
await t.enterText(
find.byKey(const material.ValueKey('erd_view_name')), long);
await t.pump();
await t.tap(find.byKey(const material.ValueKey('erd_view_submit')));
await t.pump();
await t.pump(const Duration(milliseconds: 400));
expect(find.text(long), findsNothing);
expect(find.text('${long.substring(0, 27)}…'), findsOneWidget);
});

testWidgets('a card menu says how to group more tables', variant: desktop,
(t) async {
await pumpDiagram(t, _MemoryStore());
await t.tap(find.byKey(const material.ValueKey('erd_table_users')),
buttons: kSecondaryButton);
await t.pump();
await t.pump(const Duration(milliseconds: 300));
expect(find.text('New group…'), findsOneWidget);
expect(find.textContaining('Shift+click'), findsOneWidget);
});

testWidgets('a marked card keeps its border and gets a ring outside',
variant: desktop, (t) async {
await pumpDiagram(t, _MemoryStore());
final card = find.byKey(const material.ValueKey('erd_table_users'));
material.BoxDecoration decoration() => t
.widget<material.Container>(find
.descendant(of: card, matching: find.byType(material.Container))
.first)
.decoration as material.BoxDecoration;
final plain = decoration();
expect(plain.boxShadow!.any((s) => s.spreadRadius > 0), isFalse);

await t.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await t.tap(card);
await t.pump(const Duration(milliseconds: 400));
await t.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);

final marked = decoration();
final border = marked.border! as material.Border;
expect(border.top.width, (plain.border! as material.Border).top.width);
expect(marked.boxShadow!.any((s) => s.spreadRadius > 0), isTrue);
});
}
Loading