diff --git a/lib/features/erd/erd_exporter.dart b/lib/features/erd/erd_exporter.dart new file mode 100644 index 00000000..991afe26 --- /dev/null +++ b/lib/features/erd/erd_exporter.dart @@ -0,0 +1,220 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:querya_desktop/core/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/erd/erd_router.dart'; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:querya_desktop/core/export/image_pdf.dart'; +import 'package:querya_desktop/core/export/svg_png.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/features/erd/erd_export.dart'; +import 'package:querya_desktop/shared/widgets/app_toast.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_export_menu.dart'; + +/// What the exporter needs from the diagram view (#1362): its data, the +/// colours it draws with, and the canvas it captures. +abstract interface class ErdExportSource { + /// For toasts; only used while [exportMounted]. + material.BuildContext get exportContext; + bool get exportMounted; + + /// `-erd`, or `erd` when the database name is unknown. + String get fileStem; + + Future saveFile(String name, Uint8List bytes); + + List get routes; + List get groups; + List get notes; + + /// Header colour slot of each table the user (or the schema) tinted. + Map get headerColorSlots; + material.Color slotColor(String slot); + + material.Size canvasSize(ErdLayout layout); + + /// Colours of the current theme for the SVG export. + ErdSvgColors currentThemeColors(); + Map headerFills(ErdSchema schema, String cardHex); + List svgGroups(ErdLayout layout, String backgroundHex); + List svgNotes(ErdSvgColors colors); + + /// The repaint boundary of the canvas, or null when it is not built. + RenderRepaintBoundary? get canvasBoundary; + + /// Runs [capture] with the screen-only state (the pick, a picked relation, + /// the marks for a group, the hover) hidden, and puts it back after. + Future withCleanCanvas(Future Function() capture); +} + +/// The diagram's export flows: Mermaid, DBML, SVG, PNG and PDF, and the +/// setting that picks the light palette or the current theme. +class ErdExporter { + ErdExporter(this._source); + + final ErdExportSource _source; + + void export(ErdExportAction action, ErdSchema schema, ErdLayout layout) { + final stem = _source.fileStem; + switch (action) { + case ErdExportAction.mermaid: + unawaited(_source.saveFile( + '$stem.mmd', Uint8List.fromList(utf8.encode(ErdExport.toMermaid(schema))))); + case ErdExportAction.svg: + unawaited(_exportSvg(schema, layout)); + case ErdExportAction.png: + unawaited(_exportPng(schema, layout)); + case ErdExportAction.pdfA4: + unawaited(_exportPdf(schema, layout, PdfPaper.a4)); + case ErdExportAction.pdfA3: + unawaited(_exportPdf(schema, layout, PdfPaper.a3)); + case ErdExportAction.dbml: + unawaited(_source.saveFile( + '$stem.dbml', Uint8List.fromList(utf8.encode(dbml(schema))))); + case ErdExportAction.copyDbml: + Clipboard.setData(ClipboardData(text: dbml(schema))); + showAppToast( + context: _source.exportContext, + message: 'DBML copied to the clipboard', + ); + case ErdExportAction.toggleTheme: + unawaited(_toggleExportTheme()); + case ErdExportAction.copyMermaid: + Clipboard.setData(ClipboardData(text: ErdExport.toMermaid(schema))); + showAppToast( + context: _source.exportContext, + message: 'Mermaid copied to the clipboard', + ); + } + } + + /// The DBML of what the diagram shows: its groups and notes, and the + /// header colours it draws. + String dbml(ErdSchema schema) { + final shown = {for (final t in schema.tables) t.name}; + return ErdExport.toDbml( + schema, + groups: [ + for (final g in _source.groups) + if (g.tables.any(shown.contains)) g, + ], + notes: List.of(_source.notes), + headerColors: { + for (final t in schema.tables) + if (erdHeaderSlot(t.name, _source.headerColorSlots) case final slot?) + t.name: ErdSvgColors.hex(_source.slotColor(slot).toARGB32()), + }, + ); + } + + Future _exportPng(ErdSchema schema, ErdLayout layout) async { + final currentTheme = await AppSettings.instance.getExportCurrentTheme(); + if (!_source.exportMounted) return; + final stem = _source.fileStem; + if (!currentTheme) { + final scale = ErdExport.pngPixelRatio(_source.canvasSize(layout)); + const light = ErdSvgColors(); + final svg = ErdExport.toSvg(schema, layout, + routes: _source.routes, + headerFills: _source.headerFills(schema, light.card), + groups: _source.svgGroups(layout, light.background), + notes: _source.svgNotes(light)); + await _source.saveFile('$stem.png', await svgToPng(svg, scale: scale)); + return; + } + final boundary = _source.canvasBoundary; + if (boundary == null) return; + await _source.withCleanCanvas(() async { + if (!_source.exportMounted) return; + final context = _source.exportContext; + try { + final ratio = ErdExport.pngPixelRatio(boundary.size); + if (ratio < 1) { + showAppToast( + context: context, + message: 'The diagram is larger than ' + '${ErdExport.pngMaxSide} px per side, so the PNG is at ' + '${(ratio * 100).round()}% resolution. The SVG export keeps full detail.', + ); + } + final image = await boundary.toImage(pixelRatio: ratio); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + if (data == null) throw StateError('no image data'); + await _source.saveFile('$stem.png', data.buffer.asUint8List()); + } catch (e) { + final failed = _source.exportContext; + if (failed.mounted) { + showAppToast( + context: failed, + variant: AppToastVariant.error, + message: 'Could not render the PNG ($e). ' + 'Export SVG instead: it has no size limit.', + ); + } + } + }); + } + + /// The diagram as SVG text, in the export palette: light for documents, + /// unless the setting asks for the current theme. + Future _svgText(ErdSchema schema, ErdLayout layout) async { + final currentTheme = await AppSettings.instance.getExportCurrentTheme(); + if (!_source.exportMounted) return null; + final colors = + currentTheme ? _source.currentThemeColors() : const ErdSvgColors(); + return ErdExport.toSvg(schema, layout, + routes: _source.routes, + colors: colors, + headerFills: _source.headerFills(schema, colors.card), + groups: _source.svgGroups(layout, colors.background), + notes: _source.svgNotes(colors)); + } + + Future _exportSvg(ErdSchema schema, ErdLayout layout) async { + final svg = await _svgText(schema, layout); + if (svg == null) return; + await _source.saveFile( + '${_source.fileStem}.svg', Uint8List.fromList(utf8.encode(svg))); + } + + /// The PDF export: the SVG fitted on one page of [paper]. + Future _exportPdf( + ErdSchema schema, ErdLayout layout, PdfPaper paper) async { + final svg = await _svgText(schema, layout); + if (svg == null) return; + try { + await _source.saveFile( + '${_source.fileStem}.pdf', await svgToPdf(svg, paper: paper)); + } catch (e) { + final failed = _source.exportContext; + if (failed.mounted) { + showAppToast( + context: failed, + variant: AppToastVariant.error, + message: 'Could not render the PDF ($e). Export SVG instead.', + ); + } + } + } + + /// Flips between the light export palette and the current theme. + Future _toggleExportTheme() async { + final now = !await AppSettings.instance.getExportCurrentTheme(); + await AppSettings.instance.setExportCurrentTheme(now); + final context = _source.exportContext; + if (!context.mounted) return; + showAppToast( + context: context, + message: now + ? 'Exports use the current theme' + : 'Exports use the light palette for documents', + ); + } +} diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index 760a294b..81965296 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -1,14 +1,12 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math' show max, min; import 'dart:typed_data'; -import 'dart:ui' as ui; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart' - show Clipboard, ClipboardData, HardwareKeyboard, LogicalKeyboardKey; + show HardwareKeyboard, LogicalKeyboardKey; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/erd/erd_canvas_controls.dart'; import 'package:querya_desktop/features/erd/erd_card_measure.dart'; @@ -16,6 +14,7 @@ import 'package:querya_desktop/core/erd/erd_catalog.dart'; import 'package:querya_desktop/features/erd/erd_source.dart'; import 'package:querya_desktop/features/erd/erd_export.dart'; import 'package:querya_desktop/features/erd/erd_export_data.dart'; +import 'package:querya_desktop/features/erd/erd_exporter.dart'; import 'package:querya_desktop/core/erd/erd_geometry.dart'; import 'package:querya_desktop/features/erd/erd_group_dialog.dart'; import 'package:querya_desktop/features/erd/erd_note_dialog.dart'; @@ -37,9 +36,6 @@ import 'package:querya_desktop/features/erd/widgets/erd_status_body.dart'; import 'package:querya_desktop/features/erd/widgets/erd_table_menu.dart'; import 'package:querya_desktop/features/erd/widgets/erd_views_menu.dart'; import 'package:querya_desktop/features/erd/widgets/erd_table_card.dart'; -import 'package:querya_desktop/core/export/image_pdf.dart'; -import 'package:querya_desktop/core/export/svg_png.dart'; -import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/shared/widgets/app_toast.dart'; import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; @@ -121,7 +117,10 @@ class ErdView extends material.StatefulWidget { material.State createState() => _ErdViewState(); } -class _ErdViewState extends material.State { +class _ErdViewState extends material.State + implements ErdExportSource { + late final ErdExporter _exporter = ErdExporter(this); + final _boundaryKey = material.GlobalKey(); ErdSchema? _schema; ErdLayout? _layout; @@ -1125,155 +1124,6 @@ class _ErdViewState extends material.State { /// the SVG export saves, so the two match. With the current theme it is a /// screenshot of the canvas: a picked table, the hover highlight and the edge /// label are cleared for the capture, and the pick comes back afterwards. - Future _exportPng(ErdSchema schema, ErdLayout layout) async { - final currentTheme = await AppSettings.instance.getExportCurrentTheme(); - if (!mounted) return; - if (!currentTheme) { - final scale = ErdExport.pngPixelRatio(_canvasSize(layout)); - final svg = ErdExport.toSvg(schema, layout, - routes: _routes, - headerFills: _headerFills(schema, const ErdSvgColors().card), - groups: _svgGroups(layout, const ErdSvgColors().background), - notes: _svgNotes(const ErdSvgColors())); - await _save('$_fileStem.png', await svgToPng(svg, scale: scale)); - return; - } - final boundary = _boundaryKey.currentContext?.findRenderObject() - as RenderRepaintBoundary?; - if (boundary == null) return; - // The pick, a picked relation and the marks for a group are screen - // state: none of them belongs in the image, and all come back after it. - final picked = _selected; - final pickedRelation = _pickedRelation; - final marked = Set.of(_marked); - final needsFrame = picked != null || - pickedRelation != null || - marked.isNotEmpty || - _hovered.value != null || - _edgeTipNotifier.value != null; - _hovered.value = null; - _edgeTipNotifier.value = null; - if (picked != null || pickedRelation != null || marked.isNotEmpty) { - setState(() { - _selected = null; - _pickedRelation = null; - _marked.clear(); - }); - _syncFocus(); - } - try { - if (needsFrame) await material.WidgetsBinding.instance.endOfFrame; - if (!mounted) return; - final ratio = ErdExport.pngPixelRatio(boundary.size); - if (ratio < 1) { - showAppToast( - context: context, - message: 'The diagram is larger than ' - '${ErdExport.pngMaxSide} px per side, so the PNG is at ' - '${(ratio * 100).round()}% resolution. The SVG export keeps full detail.', - ); - } - final image = await boundary.toImage(pixelRatio: ratio); - final data = await image.toByteData(format: ui.ImageByteFormat.png); - image.dispose(); - if (data == null) throw StateError('no image data'); - await _save('$_fileStem.png', data.buffer.asUint8List()); - } catch (e) { - if (mounted) { - showAppToast( - context: context, - variant: AppToastVariant.error, - message: 'Could not render the PNG ($e). ' - 'Export SVG instead: it has no size limit.', - ); - } - } finally { - if (mounted && - (picked != null || pickedRelation != null || marked.isNotEmpty)) { - setState(() { - // What the user did during the capture wins. - if (_selected == null && _pickedRelation == null) { - _selected = picked; - _pickedRelation = pickedRelation; - } - if (_marked.isEmpty) _marked.addAll(marked); - }); - _syncFocus(); - } - } - } - - /// The diagram as SVG text, in the export palette: light for documents, - /// unless the setting asks for the current theme. - Future _svgText(ErdSchema schema, ErdLayout layout) async { - final currentTheme = await AppSettings.instance.getExportCurrentTheme(); - if (!mounted) return null; - final colors = currentTheme ? _svgColors() : const ErdSvgColors(); - return ErdExport.toSvg(schema, layout, - routes: _routes, - colors: colors, - headerFills: _headerFills(schema, colors.card), - groups: _svgGroups(layout, colors.background), - notes: _svgNotes(colors)); - } - - /// The SVG export. - Future _exportSvg(ErdSchema schema, ErdLayout layout) async { - final svg = await _svgText(schema, layout); - if (svg == null) return; - await _save('$_fileStem.svg', Uint8List.fromList(utf8.encode(svg))); - } - - /// The PDF export: the SVG fitted on one page of [paper]. - Future _exportPdf( - ErdSchema schema, ErdLayout layout, PdfPaper paper) async { - final svg = await _svgText(schema, layout); - if (svg == null) return; - try { - await _save('$_fileStem.pdf', await svgToPdf(svg, paper: paper)); - } catch (e) { - if (mounted) { - showAppToast( - context: context, - variant: AppToastVariant.error, - message: 'Could not render the PDF ($e). Export SVG instead.', - ); - } - } - } - - /// The DBML of what the diagram shows: its groups and notes, and the - /// header colours it draws. - String _dbml(ErdSchema schema) { - final shown = {for (final t in schema.tables) t.name}; - return ErdExport.toDbml( - schema, - groups: [ - for (final g in _groups) - if (g.tables.any(shown.contains)) g, - ], - notes: List.of(_notes), - headerColors: { - for (final t in schema.tables) - if (erdHeaderSlot(t.name, _headerColors) case final slot?) - t.name: ErdSvgColors.hex(_slotColor(slot).toARGB32()), - }, - ); - } - - /// Flips between the light export palette and the current theme. - Future _toggleExportTheme() async { - final now = !await AppSettings.instance.getExportCurrentTheme(); - await AppSettings.instance.setExportCurrentTheme(now); - if (!mounted) return; - showAppToast( - context: context, - message: now - ? 'Exports use the current theme' - : 'Exports use the light palette for documents', - ); - } - /// Header colour of [table] on screen: the picked slot, the schema's slot, /// or the accent for a table of the current schema. material.Color _headerColor(String table) { @@ -1403,44 +1253,103 @@ class _ErdViewState extends material.State { /// cards and edges. ErdSvgColors _svgColors() => ErdExportData.themeColors(context); + // ---- ErdExportSource (the export flows live in erd_exporter.dart) ------- + + @override + material.BuildContext get exportContext => context; + + @override + bool get exportMounted => mounted; + /// `-erd`, or `erd` when the database name is unknown. - String get _fileStem => + @override + String get fileStem => widget.databaseName.isEmpty ? 'erd' : '${widget.databaseName}-erd'; - void _export(ErdExportAction action, ErdSchema schema, ErdLayout layout) { - switch (action) { - case ErdExportAction.mermaid: - _save( - '$_fileStem.mmd', - Uint8List.fromList(utf8.encode(ErdExport.toMermaid(schema)))); - case ErdExportAction.svg: - unawaited(_exportSvg(schema, layout)); - case ErdExportAction.png: - unawaited(_exportPng(schema, layout)); - case ErdExportAction.pdfA4: - unawaited(_exportPdf(schema, layout, PdfPaper.a4)); - case ErdExportAction.pdfA3: - unawaited(_exportPdf(schema, layout, PdfPaper.a3)); - case ErdExportAction.dbml: - _save('$_fileStem.dbml', - Uint8List.fromList(utf8.encode(_dbml(schema)))); - case ErdExportAction.copyDbml: - Clipboard.setData(ClipboardData(text: _dbml(schema))); - showAppToast( - context: context, - message: 'DBML copied to the clipboard', - ); - case ErdExportAction.toggleTheme: - unawaited(_toggleExportTheme()); - case ErdExportAction.copyMermaid: - Clipboard.setData(ClipboardData(text: ErdExport.toMermaid(schema))); - showAppToast( - context: context, - message: 'Mermaid copied to the clipboard', - ); + @override + Future saveFile(String name, Uint8List bytes) => _save(name, bytes); + + @override + List get routes => _routes; + + @override + List get groups => _groups; + + @override + List get notes => _notes; + + @override + Map get headerColorSlots => _headerColors; + + @override + material.Color slotColor(String slot) => _slotColor(slot); + + @override + material.Size canvasSize(ErdLayout layout) => _canvasSize(layout); + + @override + ErdSvgColors currentThemeColors() => _svgColors(); + + @override + Map headerFills(ErdSchema schema, String cardHex) => + _headerFills(schema, cardHex); + + @override + List svgGroups(ErdLayout layout, String backgroundHex) => + _svgGroups(layout, backgroundHex); + + @override + List svgNotes(ErdSvgColors colors) => _svgNotes(colors); + + @override + RenderRepaintBoundary? get canvasBoundary => + _boundaryKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + + @override + Future withCleanCanvas(Future Function() capture) async { + // The pick, a picked relation and the marks for a group are screen + // state: none of them belongs in the image, and all come back after it. + final picked = _selected; + final pickedRelation = _pickedRelation; + final marked = Set.of(_marked); + final hadScreenState = picked != null || + pickedRelation != null || + marked.isNotEmpty; + final needsFrame = hadScreenState || + _hovered.value != null || + _edgeTipNotifier.value != null; + _hovered.value = null; + _edgeTipNotifier.value = null; + if (hadScreenState) { + setState(() { + _selected = null; + _pickedRelation = null; + _marked.clear(); + }); + _syncFocus(); + } + try { + if (needsFrame) await material.WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + await capture(); + } finally { + if (mounted && hadScreenState) { + setState(() { + // What the user did during the capture wins. + if (_selected == null && _pickedRelation == null) { + _selected = picked; + _pickedRelation = pickedRelation; + } + if (_marked.isEmpty) _marked.addAll(marked); + }); + _syncFocus(); + } } } + void _export(ErdExportAction action, ErdSchema schema, ErdLayout layout) => + _exporter.export(action, schema, layout); + /// Pointer over the canvas ([p] in canvas space): names the relation within /// 6 screen px of it, if any, whatever the zoom. void _hoverCanvas(material.Offset p) { diff --git a/test/features/erd/erd_exporter_test.dart b/test/features/erd/erd_exporter_test.dart new file mode 100644 index 00000000..a8760f50 --- /dev/null +++ b/test/features/erd/erd_exporter_test.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/erd/erd_layout.dart'; +import 'package:querya_desktop/core/erd/erd_model.dart'; +import 'package:querya_desktop/core/erd/erd_router.dart'; +import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:querya_desktop/features/erd/erd_export.dart'; +import 'package:querya_desktop/features/erd/erd_exporter.dart'; +import 'package:querya_desktop/features/erd/widgets/erd_export_menu.dart'; + +/// #1362: the export flows no longer live in the view state, so they can be +/// driven with a fake source. +class _FakeSource implements ErdExportSource { + final saved = {}; + + @override + material.BuildContext get exportContext => throw UnimplementedError(); + + @override + bool get exportMounted => true; + + @override + String get fileStem => 'shop-erd'; + + @override + Future saveFile(String name, Uint8List bytes) async { + saved[name] = utf8.decode(bytes); + } + + @override + List get routes => const []; + + @override + List get groups => const []; + + @override + List get notes => const []; + + @override + Map get headerColorSlots => const {}; + + @override + material.Color slotColor(String slot) => const material.Color(0xFF123456); + + @override + material.Size canvasSize(ErdLayout layout) => layout.size; + + @override + ErdSvgColors currentThemeColors() => const ErdSvgColors(); + + @override + Map headerFills(ErdSchema schema, String cardHex) => + const {}; + + @override + List svgGroups(ErdLayout layout, String backgroundHex) => + const []; + + @override + List svgNotes(ErdSvgColors colors) => const []; + + @override + RenderRepaintBoundary? get canvasBoundary => null; + + @override + Future withCleanCanvas(Future Function() capture) => capture(); +} + +void main() { + const schema = ErdSchema( + tables: [ + ErdTable(name: 'users', columns: [ + ErdColumn(name: 'id', type: 'integer', isPrimaryKey: true), + ]), + ErdTable(name: 'orders', columns: [ + ErdColumn(name: 'id', type: 'integer', isPrimaryKey: true), + ErdColumn(name: 'user_id', type: 'integer', isForeignKey: true), + ]), + ], + relations: [ + ErdRelation( + fromTable: 'orders', + fromColumn: 'user_id', + toTable: 'users', + toColumn: 'id', + ), + ], + ); + + test('Mermaid is saved as .mmd', () async { + final source = _FakeSource(); + final layout = ErdLayout.compute(schema); + + ErdExporter(source).export(ErdExportAction.mermaid, schema, layout); + await Future.delayed(Duration.zero); + + expect(source.saved.keys, ['shop-erd.mmd']); + expect(source.saved['shop-erd.mmd'], contains('erDiagram')); + }); + + test('DBML is saved as .dbml and names the tables', () async { + final source = _FakeSource(); + final layout = ErdLayout.compute(schema); + + ErdExporter(source).export(ErdExportAction.dbml, schema, layout); + await Future.delayed(Duration.zero); + + final dbml = source.saved['shop-erd.dbml']!; + expect(dbml, contains('Table "users"')); + expect(dbml, contains('Table "orders"')); + expect(dbml, contains('Ref: "orders"."user_id" > "users"."id"')); + }); + + test('dbml() leaves out groups of tables that are not shown', () { + final exporter = ErdExporter(_FakeSource()); + + expect(exporter.dbml(schema), isNot(contains('TableGroup'))); + }); +}