Dashboard chart flow

This commit is contained in:
mr
2024-07-17 13:28:02 +02:00
parent 7e4687853f
commit dce96e338c
136 changed files with 9016 additions and 425 deletions

View File

@@ -0,0 +1,787 @@
// ignore_for_file: avoid_positional_boolean_parameters
import 'dart:io';
import 'dart:convert';
import 'package:uuid/uuid.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_flow_chart/flutter_flow_chart.dart';
import 'package:flutter_flow_chart/src/flow_chart_menu.dart';
import 'package:flutter_flow_chart/src/flow_chart_selected_menu.dart';
import 'package:flutter_flow_chart/src/ui/draw_arrow.dart';
import 'package:flutter_flow_chart/src/ui/segment_handler.dart';
/// Listener definition for a new connection
typedef ConnectionListener = void Function(
FlowElement srcElement,
FlowElement destElement,
);
/// Class to store all the scene elements.
/// This also acts as the controller to the flow_chart widget
/// It notifies changes to [FlowChart]
//
class Dashboard extends ChangeNotifier {
GlobalKey<FlowChartSelectedMenuState> selectedMenuKey = GlobalKey<FlowChartSelectedMenuState>();
GlobalKey<FlowChartMenuState> chartMenuKey = GlobalKey<FlowChartMenuState>();
GlobalKey<ChartWidgetState> chartKey = GlobalKey<ChartWidgetState>();
List<Map<String, dynamic>> tempHistory = [];
List<Map<String, dynamic>> history = [];
String name;
String defaultName = "";
bool isMenu = true;
bool isInfo = true;
bool isOpened = false;
Color defaultColor = Colors.black;
Map<String, dynamic>? loadedGraph;
double defaultStroke = 1.7;
double defaultDashSpace = 0;
double defaultDashWidth = 0;
double defaultBackWidth = 10;
double defaultForwardWidth = 10;
final void Function()? save;
///
Dashboard({
required this.name,
this.save,
Offset? handlerFeedbackOffset,
this.isMenu = true,
this.defaultDashSpace = 0,
this.defaultDashWidth = 0,
this.blockDefaultZoomGestures = false,
this.minimumZoomFactor = 0.25,
this.defaultArrowDirection = ArrowDirection.forward,
this.defaultArrowStyle = ArrowStyle.curve,
this.loadedGraph,
}) : elements = [],
_dashboardPosition = Offset.zero,
dashboardSize = Size.zero,
gridBackgroundParams = GridBackgroundParams() {
// This is a workaround to set the handlerFeedbackOffset
// to improve the user experience on devices with touch screens
// This will prevent the handler being covered by user's finger
defaultName = name;
if (loadedGraph != null) { deserialize(loadedGraph!); }
if (handlerFeedbackOffset != null) {
this.handlerFeedbackOffset = handlerFeedbackOffset;
} else {
if (kIsWeb) {
this.handlerFeedbackOffset = Offset.zero;
} else {
if (Platform.isIOS || Platform.isAndroid) {
this.handlerFeedbackOffset = const Offset(0, -50);
} else {
this.handlerFeedbackOffset = Offset.zero;
}
}
}
tempHistory = [];
history = [];
addToHistory();
}
///
factory Dashboard.fromMap(Map<String, dynamic> map) {
final d = Dashboard(
name: map['name'] as String,
isMenu: map['isMenu'] as bool,
defaultDashSpace: map['defaultDashSpace'] as double? ?? 0,
defaultDashWidth: map['defaultDashWidth'] as double? ?? 0,
defaultArrowDirection: ArrowDirection.values[
map['defaultArrowDirection'] as int? ?? 0],
defaultArrowStyle: ArrowStyle.values[map['arrowStyle'] as int? ?? 0],
)
..arrows = List<ArrowPainter>.from(
(map['arrows'] as List<dynamic>).map<ArrowPainter>(
(x) => ArrowPainter.fromMap(x as Map<String, dynamic>),
),
)
..elements = List<FlowElement>.from(
(map['elements'] as List<dynamic>).map<FlowElement>(
(x) => FlowElement.fromMap(x as Map<String, dynamic>),
),
)
..dashboardSize = Size(
map['dashboardSizeWidth'] as double? ?? 0,
map['dashboardSizeHeight'] as double? ?? 0,
);
if (map['gridBackgroundParams'] != null) {
d.gridBackgroundParams = GridBackgroundParams.fromMap(
map['gridBackgroundParams'] as Map<String, dynamic>,
);
}
d
..blockDefaultZoomGestures =
(map['blockDefaultZoomGestures'] as bool? ?? false)
..minimumZoomFactor = map['minimumZoomFactor'] as double? ?? 0.25;
return d;
}
void copyFromMap(Map<String, dynamic> map) {
defaultArrowStyle = ArrowStyle.values[map['arrowStyle'] as int? ?? 0];
defaultDashSpace = map['defaultDashSpace'] as double? ?? 0;
defaultDashWidth = map['defaultDashWidth'] as double? ?? 0;
defaultArrowDirection = ArrowDirection.values[
map['defaultArrowDirection'] as int? ?? 0];
arrows = List<ArrowPainter>.from(
(map['arrows'] as List<dynamic>).map<ArrowPainter>(
(x) => ArrowPainter.fromMap(x as Map<String, dynamic>),
),
);
elements = List<FlowElement>.from(
(map['elements'] as List<dynamic>).map<FlowElement>(
(x) => FlowElement.fromMap(x as Map<String, dynamic>),
),
);
dashboardSize = Size(
map['dashboardSizeWidth'] as double? ?? 0,
map['dashboardSizeHeight'] as double? ?? 0,
);
if (map['gridBackgroundParams'] != null) {
gridBackgroundParams = GridBackgroundParams.fromMap(
map['gridBackgroundParams'] as Map<String, dynamic>,
);
}
blockDefaultZoomGestures =
(map['blockDefaultZoomGestures'] as bool? ?? false);
minimumZoomFactor = map['minimumZoomFactor'] as double? ?? 0.25;
if (save != null) { save!(); }
}
///
factory Dashboard.fromJson(String source) =>
Dashboard.fromMap(json.decode(source) as Map<String, dynamic>);
/// The current elements in the dashboard
List<FlowElement> elements;
List<FlowElement> get elementSelected =>
elements.where((element) => element.isSelected).toList();
Offset _dashboardPosition;
/// Dashboard size
Size dashboardSize;
List<ArrowPainter> arrows = [];
List<ArrowPainter> get arrowsSelected =>
arrows.where((element) => element.isSelected).toList();
/// The default style for the new created arrow
ArrowStyle defaultArrowStyle;
ArrowDirection defaultArrowDirection = ArrowDirection.forward;
/// [handlerFeedbackOffset] sets an offset for the handler when user
/// is dragging it.
/// This can be used to prevent the handler being covered by user's
/// finger on touch screens.
late Offset handlerFeedbackOffset;
/// Background parameters.
GridBackgroundParams gridBackgroundParams;
///
bool blockDefaultZoomGestures;
/// minimum zoom factor allowed
/// default is 0.25
/// setting it to 1 will prevent zooming out
/// setting it to 0 will remove the limit
double minimumZoomFactor;
final List<ConnectionListener> _connectionListeners = [];
Map<String, dynamic> serialize() {
Map<String, dynamic> graph = {};
graph['elements'] = {};
for(var el in elements) {
graph['elements'][el.id] = el.serialize();
}
graph['arrows'] = arrows.map((e) => e.serialize()).toList();
return graph;
}
void deserialize(Map<String, dynamic> graph) {
elements.clear();
arrows.clear();
for(var el in graph['elements'].values) {
List<ConnectionParams> nexts = [];
var flow = FlowElement.deserialize(el);
for(var ar in graph['arrows']) {
nexts.add(ConnectionParams(
srcElementId: ar['from']['id'],
destElementId: ar['to']['id'],
arrowParams: ArrowParams.fromMap(ar["params"]),
pivots: [
Pivot(Offset(ar['from']['x'], (ar['from']['y']))),
Pivot(Offset(ar['to']['x'], (ar['to']['y'])))
]
));
}
flow.next = nexts;
}
}
/// add listener called when a new connection is created
void addConnectionListener(ConnectionListener listener) {
_connectionListeners.add(listener);
}
/// remove connection listener
void removeConnectionListener(ConnectionListener listener) {
_connectionListeners.remove(listener);
}
/// set grid background parameters
void setGridBackgroundParams(GridBackgroundParams params) {
gridBackgroundParams = params;
notifyListeners();
}
/// set the feedback offset to help on mobile device to see the
/// end of arrow and not hiding behind the finger when moving it
void setHandlerFeedbackOffset(Offset offset) {
handlerFeedbackOffset = offset;
}
void addToHistory() {
if (tempHistory.length >= 50) { tempHistory.removeAt(0); }
tempHistory.add(toMap());
if (save != null) { save!(); }
history = tempHistory.map((e) => e).toList();
chartKey.currentState?.setState(() { });
chartMenuKey.currentState?.setState(() { });
}
bool isBack = false;
void back() {
tempHistory.removeLast();
if (tempHistory.length == 0) return;
copyFromMap(tempHistory.last);
chartKey.currentState?.setState(() { });
chartMenuKey.currentState?.setState(() { });
}
bool canBack() {
return tempHistory.length > 1;
}
bool canForward() {
return tempHistory.length < history.length;
}
void forward() {
if (canForward()) {
tempHistory.add(history[tempHistory.length]);
copyFromMap(tempHistory.last);
chartKey.currentState?.setState(() { });
chartMenuKey.currentState?.setState(() { });
}
}
/// set [resizable] element property
void setElementResizable(
FlowElement element,
bool resizable, {
bool notify = true,
}) {
element.isResizing = resizable;
if (notify) notifyListeners();
}
FlowElement? getElement(String id, {bool notify = true}) {
try { return elements.firstWhere((element) => element.id == id); }
catch (e) { return null; }
}
/// add a [FlowElement] to the dashboard
void addElement(FlowElement element, {bool notify = true}) {
if (element.id.isEmpty) {
element.id = const Uuid().v4();
}
element.setScale(1, gridBackgroundParams.scale);
elements.add(element);
if (notify) {
notifyListeners();
}
}
void setElement(FlowElement element, FlowElement set, {bool notify = true}) {
element = set;
if (notify) {
notifyListeners();
}
}
@override
void notifyListeners() {
addToHistory();
super.notifyListeners();
}
/// Set a new [style] to the arrow staring from [src] pointing to [dest].
/// If [notify] is true the dasboard is refreshed.
/// The [tension] parameter is used when [style] is [ArrowStyle.segmented] to
/// set the curve strength on pivot points. 0 means no curve.
void setArrowStyle(
FlowElement src,
FlowElement dest,
ArrowStyle style, {
bool notify = true,
double tension = 1.0,
}) {
for (final conn in src.next) {
if (conn.destElementId == dest.id) {
conn.arrowParams.style = style;
conn.arrowParams.tension = tension;
break;
}
}
if (notify) {
notifyListeners();
}
}
/// Set a new [style] to the arrow staring from the [handler] of [src]
/// element.
/// If [notify] is true the dasboard is refreshed.
/// The [tension] parameter is used when [style] is [ArrowStyle.segmented] to
/// set the curve strength on pivot points. 0 means no curve.
void setArrowStyleByHandler(
FlowElement src,
Handler handler,
ArrowStyle style, {
bool notify = true,
double tension = 1.0,
}) {
// find arrows that start from [src] inside [handler]
for (final conn in src.next) {
if (conn.arrowParams.startArrowPosition == handler.toAlignment()) {
conn.arrowParams.tension = tension;
conn.arrowParams.style = style;
}
}
// find arrow that ends to this [src] inside [handler]
for (final element in elements) {
for (final conn in element.next) {
if (conn.arrowParams.endArrowPosition == handler.toAlignment() &&
conn.destElementId == src.id) {
conn.arrowParams.tension = tension;
conn.arrowParams.style = style;
}
}
}
if (notify) {
notifyListeners();
}
}
/// find the element by its [id]
int findElementIndexById(String id) {
return elements.indexWhere((element) => element.id == id);
}
/// find the element by its [id] for convenience
/// return null if not found
FlowElement? findElementById(String id) {
try {
return elements.firstWhere((element) => element.id == id);
} catch (e) {
return null;
}
}
/// find the connection from [srcElement] to [destElement]
/// return null if not found.
/// In case of multiple connections, first connection is returned.
ConnectionParams? findConnectionByElements(
FlowElement srcElement,
FlowElement destElement,
) {
try {
return srcElement.next
.firstWhere((element) => element.destElementId == destElement.id);
} catch (e) {
return null;
}
}
/// find the source element of the [dest] element.
FlowElement? findSrcElementByDestElement(FlowElement dest) {
for (final element in elements) {
for (final connection in element.next) {
if (connection.destElementId == dest.id) {
return element;
}
}
}
return null;
}
/// remove all elements
void removeAllElements({bool notify = true}) {
elements.clear();
if (notify) notifyListeners();
}
/// remove the [handler] connection of [element]
void removeElementConnection(
FlowElement element,
Handler handler, {
bool notify = true,
}) {
Alignment alignment;
switch (handler) {
case Handler.topCenter:
alignment = Alignment.topCenter;
case Handler.bottomCenter:
alignment = Alignment.bottomCenter;
case Handler.leftCenter:
alignment = Alignment.centerLeft;
case Handler.rightCenter:
alignment = Alignment.centerRight;
}
var isSrc = false;
for (final connection in element.next) {
if (connection.arrowParams.startArrowPosition == alignment) {
isSrc = true;
break;
}
}
if (isSrc) {
element.next.removeWhere(
(handlerParam) =>
handlerParam.arrowParams.startArrowPosition == alignment,
);
} else {
final src = findSrcElementByDestElement(element);
if (src != null) {
src.next.removeWhere(
(handlerParam) => handlerParam.destElementId == element.id,
);
}
}
if (notify) notifyListeners();
}
/// dissect an element connection
/// [handler] is the handler that is in connection
/// [point] is the point where the connection is dissected
/// if [point] is null, point is automatically calculated
void dissectElementConnection(
FlowElement element,
Handler handler, {
Offset? point,
bool notify = true,
}) {
Alignment alignment;
switch (handler) {
case Handler.topCenter:
alignment = Alignment.topCenter;
case Handler.bottomCenter:
alignment = Alignment.bottomCenter;
case Handler.leftCenter:
alignment = Alignment.centerLeft;
case Handler.rightCenter:
alignment = Alignment.centerRight;
}
ConnectionParams? conn;
var newPoint = Offset.zero;
if (point == null) {
try {
// assuming element is the src
conn = element.next.firstWhere(
(handlerParam) =>
handlerParam.arrowParams.startArrowPosition == alignment,
);
if (conn.arrowParams.style != ArrowStyle.segmented) return;
final dest = findElementById(conn.destElementId);
newPoint = (dest!
.getHandlerPosition(conn.arrowParams.endArrowPosition) +
element
.getHandlerPosition(conn.arrowParams.startArrowPosition)) /
2;
} catch (e) {
// apparently is not
final src = findSrcElementByDestElement(element)!;
conn = src.next.firstWhere(
(handlerParam) => handlerParam.destElementId == element.id,
);
if (conn.arrowParams.style != ArrowStyle.segmented) return;
newPoint = (element
.getHandlerPosition(conn.arrowParams.endArrowPosition) +
src.getHandlerPosition(conn.arrowParams.startArrowPosition)) /
2;
}
} else {
newPoint = point;
}
conn?.dissect(newPoint);
if (notify && conn != null) {
notifyListeners();
}
}
/// remove the dissection of the connection
void removeDissection(Pivot pivot, {bool notify = true}) {
for (final element in elements) {
for (final connection in element.next) {
connection.pivots.removeWhere((item) => item == pivot);
}
}
if (notify) notifyListeners();
}
/// remove the connection from [srcElement] to [destElement]
void removeConnectionByElements(
FlowElement srcElement,
FlowElement destElement, {
bool notify = true,
}) {
srcElement.next.removeWhere(
(handlerParam) => handlerParam.destElementId == destElement.id,
);
if (notify) notifyListeners();
}
/// remove all the connection from the [element]
void removeElementConnections(FlowElement element, {bool notify = true}) {
element.next.clear();
if (notify) notifyListeners();
}
/// remove all the elements with [id] from the dashboard
void removeElementById(String id, {bool notify = true}) {
// remove the element
var elementId = '';
elements.removeWhere((element) {
if (element.id == id) {
elementId = element.id;
}
return element.id == id;
});
// remove all connections to the elements found
for (final e in elements) {
e.next.removeWhere((handlerParams) {
return elementId.contains(handlerParams.destElementId);
});
}
if (notify) notifyListeners();
}
/// remove element
/// return true if it has been removed
bool removeElement(FlowElement element, {bool notify = true}) {
// remove the element
var found = false;
final elementId = element.id;
elements.removeWhere((e) {
if (e.id == element.id) found = true;
return e.id == element.id;
});
// remove all connections to the element
for (final e in elements) {
e.next.removeWhere(
(handlerParams) => handlerParams.destElementId == elementId,
);
}
if (notify) notifyListeners();
return found;
}
double currentZoom = 1.0;
/// [factor] needs to be a non negative value.
/// 1 is the default value.
/// Giving a value above 1 will zoom the dashboard by the given factor
/// and vice versa. Negative values will be ignored.
/// [zoomFactor] will not go below [minimumZoomFactor]
/// [focalPoint] is the point where the zoom is centered
/// default is the center of the dashboard
void setZoomFactor(double factor, {Offset? focalPoint}) {
if (factor < minimumZoomFactor || gridBackgroundParams.scale == factor) {
return;
}
focalPoint ??= Offset(dashboardSize.width / 2, dashboardSize.height / 2);
for (final element in elements) {
// applying new zoom
element
..position = (element.position - focalPoint) /
gridBackgroundParams.scale *
factor +
focalPoint
..setScale(gridBackgroundParams.scale, factor);
for (final conn in element.next) {
for (final pivot in conn.pivots) {
pivot.setScale(gridBackgroundParams.scale, focalPoint, factor);
}
}
}
gridBackgroundParams.setScale(factor, focalPoint);
currentZoom = factor;
notifyListeners();
}
double getZoomFactor() {
return currentZoom;
}
/// shorthand to get the current zoom factor
double get zoomFactor {
return gridBackgroundParams.scale;
}
/// needed to know the diagram widget position to compute
/// offsets for drag and drop elements
void setDashboardPosition(Offset position) {
_dashboardPosition = position;
}
/// Get the position.
Offset get position => _dashboardPosition;
/// needed to know the diagram widget size
void setDashboardSize(Size size) {
dashboardSize = size;
}
/// make an arrow connection from [sourceElement] to
/// the elements with id [destId]
/// [arrowParams] definition of arrow parameters
void addNextById(
FlowElement sourceElement,
String destId,
ArrowParams arrowParams, {
bool notify = true,
}) {
var found = 0;
arrowParams.setScale(1, gridBackgroundParams.scale);
for (var i = 0; i < elements.length; i++) {
if (elements[i].id == destId) {
// if the [id] already exist, remove it and add this new connection
/* sourceElement.next
.removeWhere((element) => element.destElementId == destId); */
final conn = ConnectionParams(
srcElementId: sourceElement.id,
destElementId: elements[i].id,
arrowParams: arrowParams,
pivots: [],
);
sourceElement.next.add(conn);
for (final listener in _connectionListeners) {
listener(sourceElement, elements[i]);
}
found++;
}
}
if (found == 0) {
debugPrint('Element with $destId id not found!');
return;
}
if (notify) {
notifyListeners();
}
}
//******************************* */
/// manage load/save using json
Map<String, dynamic> toMap() {
return <String, dynamic>{
'name': name,
'isMenu': isMenu,
'arrows': arrows.map((x) => x.toMap()).toList(),
'elements': elements.map((x) => x.toMap()).toList(),
'dashboardSizeWidth': dashboardSize.width,
'dashboardSizeHeight': dashboardSize.height,
'gridBackgroundParams': gridBackgroundParams.toMap(),
'blockDefaultZoomGestures': blockDefaultZoomGestures,
'minimumZoomFactor': minimumZoomFactor,
'defaultArrowDirection': defaultArrowDirection.index,
'defaultDashSpace': defaultDashSpace,
'defaultDashWidth': defaultDashWidth,
'arrowStyle': defaultArrowStyle.index,
};
}
///
String toJson() => json.encode(toMap());
///
String prettyJson() {
final spaces = ' ' * 2;
final encoder = JsonEncoder.withIndent(spaces);
return encoder.convert(toMap());
}
/// recenter the dashboard
void recenter() {
final center = Offset(dashboardSize.width / 2, dashboardSize.height / 2);
gridBackgroundParams.offset = center;
if (elements.isNotEmpty) {
final currentDeviation = elements.first.position - center;
for (final element in elements) {
element.position -= currentDeviation;
for (final next in element.next) {
for (final pivot in next.pivots) {
pivot.pivot -= currentDeviation;
}
}
}
}
notifyListeners();
}
/// save the dashboard into [completeFilePath]
void saveDashboard(String completeFilePath) {
File(completeFilePath).writeAsStringSync(prettyJson(), flush: true);
}
/// clear the dashboard and load the new one
void loadDashboard(String completeFilePath) {
final f = File(completeFilePath);
if (f.existsSync()) {
elements.clear();
final source = json.decode(f.readAsStringSync()) as Map<String, dynamic>;
gridBackgroundParams = GridBackgroundParams.fromMap(
source['gridBackgroundParams'] as Map<String, dynamic>,
);
blockDefaultZoomGestures = source['blockDefaultZoomGestures'] as bool;
minimumZoomFactor = source['minimumZoomFactor'] as double;
dashboardSize = Size(
source['dashboardSizeWidth'] as double,
source['dashboardSizeHeight'] as double,
);
final loadedElements = List<FlowElement>.from(
(source['elements'] as List<dynamic>).map<FlowElement>(
(x) => FlowElement.fromMap(x as Map<String, dynamic>),
),
);
elements
..clear()
..addAll(loadedElements);
recenter();
}
}
}

View File

@@ -0,0 +1,64 @@
import 'dart:convert';
import 'dart:ui';
import 'package:flutter_flow_chart/src/ui/draw_arrow.dart';
import 'package:flutter_flow_chart/src/ui/segment_handler.dart';
/// Connection parameters.
class ConnectionParams {
///
ConnectionParams({
required this.srcElementId,
required this.destElementId,
required this.arrowParams,
List<Pivot>? pivots,
}) : pivots = pivots ?? [];
///
factory ConnectionParams.fromMap(Map<String, dynamic> map) {
return ConnectionParams(
srcElementId: map['srcElementId'] as String,
destElementId: map['destElementId'] as String,
arrowParams: ArrowParams.fromMap(map['arrowParams'] as Map<String, dynamic>),
pivots: (map['pivots'] as List?)
?.map<Pivot>(
(pivot) => Pivot.fromMap(pivot as Map<String, dynamic>),
)
.toList() ??
[],
);
}
///
factory ConnectionParams.fromJson(String source) =>
ConnectionParams.fromMap(json.decode(source) as Map<String, dynamic>);
/// Unique element ID where this connection points.
final String destElementId;
final String srcElementId;
/// Arrow parameters.
final ArrowParams arrowParams;
/// List of pivot points for the segmented arrow style.
final List<Pivot> pivots;
///
Map<String, dynamic> toMap() {
return <String, dynamic>{
'srcElementId' : srcElementId,
'destElementId': destElementId,
'arrowParams': arrowParams.toMap(),
'pivots': pivots.map((pivots) => pivots.toMap()).toList(),
};
}
/// Divide the connection into segments
void dissect(Offset point) {
pivots.add(Pivot(point));
}
///
String toJson() => json.encode(toMap());
}

View File

@@ -0,0 +1,379 @@
// ignore_for_file: avoid_positional_boolean_parameters, avoid_dynamic_calls
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/flutter_flow_chart.dart';
import 'package:flutter_flow_chart/src/elements/connection_params.dart';
import 'package:uuid/uuid.dart';
/// Kinf od element
enum ElementKind {
///
rectangle,
///
diamond,
///
storage,
///
oval,
///
parallelogram,
///
hexagon,
///
widget,
}
/// Handler supported by elements
enum Handler {
///
topCenter,
///
bottomCenter,
///
rightCenter,
///
leftCenter;
/// Convert to [Alignment]
Alignment toAlignment() {
switch (this) {
case Handler.topCenter:
return Alignment.topCenter;
case Handler.bottomCenter:
return Alignment.bottomCenter;
case Handler.rightCenter:
return Alignment.centerRight;
case Handler.leftCenter:
return Alignment.centerLeft;
}
}
}
/// Class to store [ElementWidget]s and notify its changes
class FlowElement extends ChangeNotifier {
bool isSelected = false;
///
FlowElement({
Offset position = Offset.zero,
String? id,
this.size = Size.zero,
this.text = '',
this.textColor = Colors.black,
this.fontFamily,
this.widget,
this.textSize = 24,
this.textIsBold = false,
this.kind = ElementKind.rectangle,
this.handlers = const [
Handler.topCenter,
Handler.bottomCenter,
Handler.rightCenter,
Handler.leftCenter,
],
this.handlerSize = 15.0,
this.backgroundColor = Colors.white,
this.borderColor = Colors.blue,
this.borderThickness = 3,
this.elevation = 4,
List<ConnectionParams>? next,
}) : next = next ?? [],
id = id ?? const Uuid().v4(),
isResizing = false,
// fixing offset issue under extreme scaling
position = position -
Offset(
size.width / 2 + handlerSize / 2,
size.height / 2 + handlerSize / 2,
);
bool isElement(Offset pos) {
print("${position.dx} <= ${pos.dx} <= ${position.dx + size.width}");
if (position.dx <= pos.dx && pos.dx <= position.dx + size.width) {
return position.dy <= pos.dy && pos.dy <= position.dy + size.height;
}
return false;
}
factory FlowElement.fromMap(Map<String, dynamic> map) {
final e = FlowElement(
widget: map['widget'] as Widget?,
size: Size(map['size.width'] as double, map['size.height'] as double),
text: map['text'] as String,
textColor: Color(map['textColor'] as int),
fontFamily: map['fontFamily'] as String?,
textSize: map['textSize'] as double,
textIsBold: map['textIsBold'] as bool,
kind: ElementKind.values[map['kind'] as int],
handlers: List<Handler>.from(
(map['handlers'] as List<dynamic>).map<Handler>(
(x) => Handler.values[x as int],
),
),
handlerSize: map['handlerSize'] as double,
backgroundColor: Color(map['backgroundColor'] as int),
borderColor: Color(map['borderColor'] as int),
borderThickness: map['borderThickness'] as double,
elevation: map['elevation'] as double,
next: (map['next'] as List).isNotEmpty
? List<ConnectionParams>.from(
(map['next'] as List<dynamic>).map<dynamic>(
(x) => ConnectionParams.fromMap(x as Map<String, dynamic>),
),
)
: [],
)
..setId(map['id'] as String)
..position = Offset(
map['positionDx'] as double,
map['positionDy'] as double,
);
return e;
}
///
factory FlowElement.fromJson(String source) =>
FlowElement.fromMap(json.decode(source) as Map<String, dynamic>);
/// Unique id set when adding a [FlowElement] with [Dashboard.addElement()]
late String id;
/// The position of the [FlowElement]
Offset position;
/// The size of the [FlowElement]
Size size;
Widget? widget;
/// Element text
String text;
/// Text color
Color textColor;
/// Text font family
String? fontFamily;
/// Text size
double textSize;
/// Makes text bold if true
bool textIsBold;
/// Element shape
ElementKind kind;
/// Connection handlers
List<Handler> handlers;
/// The size of element handlers
double handlerSize;
/// Background color of the element
Color backgroundColor;
/// Border color of the element
Color borderColor;
/// Border thickness of the element
double borderThickness;
/// Shadow elevation
double elevation;
/// List of connections from this element
List<ConnectionParams> next;
/// Element text
bool isResizing;
@override
String toString() {
return 'kind: $kind text: $text';
}
/// Get the handler center of this handler for the given alignment.
Offset getHandlerPosition(Alignment alignment) {
// The zero position coordinate is the top-left of this element.
final ret = Offset(
position.dx + (size.width * ((alignment.x + 1) / 2)) + handlerSize / 2,
position.dy + (size.height * ((alignment.y + 1) / 2) + handlerSize / 2),
);
return ret;
}
/// When setting to true, a handler will disply at the element bottom right
/// to let the user to resize it. When finish it will disappear.
void setIsResizing(bool resizing) {
isResizing = resizing;
notifyListeners();
}
/// Sets a new scale
void setScale(double currentZoom, double factor) {
size = size / currentZoom * factor;
handlerSize = handlerSize / currentZoom * factor;
textSize = textSize / currentZoom * factor;
for (final element in next) {
element.arrowParams.setScale(currentZoom, factor);
}
notifyListeners();
}
/// Used internally to set an unique Uuid to this element
void setId(String id) {
this.id = id;
}
/// Set text
void setText(String text) {
this.text = text;
notifyListeners();
}
/// Set text color
void setTextColor(Color color) {
textColor = color;
notifyListeners();
}
/// Set text font family
void setFontFamily(String? fontFamily) {
this.fontFamily = fontFamily;
notifyListeners();
}
/// Set text size
void setTextSize(double size) {
textSize = size;
notifyListeners();
}
/// Set text bold
void setTextIsBold(bool isBold) {
textIsBold = isBold;
notifyListeners();
}
/// Set background color
void setBackgroundColor(Color color) {
backgroundColor = color;
notifyListeners();
}
/// Set border color
void setBorderColor(Color color) {
borderColor = color;
notifyListeners();
}
/// Set border thickness
void setBorderThickness(double thickness) {
borderThickness = thickness;
notifyListeners();
}
/// Set elevation
void setElevation(double elevation) {
this.elevation = elevation;
notifyListeners();
}
/// Change element position in the dashboard
void changePosition(Offset newPosition) {
position = newPosition;
notifyListeners();
}
/// Change element size
void changeSize(Size newSize) {
size = newSize;
if (size.width < 40) size = Size(40, size.height);
if (size.height < 40) size = Size(size.width, 40);
notifyListeners();
}
@override
bool operator ==(covariant FlowElement other) {
if (identical(this, other)) return true;
return other.id == id;
}
@override
int get hashCode {
return position.hashCode ^
size.hashCode ^
text.hashCode ^
textColor.hashCode ^
fontFamily.hashCode ^
textSize.hashCode ^
textIsBold.hashCode ^
id.hashCode ^
kind.hashCode ^
handlers.hashCode ^
handlerSize.hashCode ^
backgroundColor.hashCode ^
borderColor.hashCode ^
borderThickness.hashCode ^
elevation.hashCode ^
next.hashCode;
}
///
Map<String, dynamic> toMap() {
return <String, dynamic>{
'widget': widget,
'positionDx': position.dx,
'positionDy': position.dy,
'size.width': size.width,
'size.height': size.height,
'text': text,
'textColor': textColor.value,
'fontFamily': fontFamily,
'textSize': textSize,
'textIsBold': textIsBold,
'id': id,
'kind': kind.index,
'handlers': handlers.map((x) => x.index).toList(),
'handlerSize': handlerSize,
'backgroundColor': backgroundColor.value,
'borderColor': borderColor.value,
'borderThickness': borderThickness,
'elevation': elevation,
'next': next.map((x) => x.toMap()).toList(),
};
}
///
String toJson() => json.encode(toMap());
Map<String, dynamic> serialize() {
Map<String, dynamic> graphElement = {};
graphElement['id'] = id;
graphElement['x'] = position.dx;
graphElement['y'] = position.dy;
graphElement['width'] = size.width;
graphElement['height'] = size.height;
return graphElement;
}
static FlowElement deserialize(Map<String, dynamic> map) {
return FlowElement(
id: map['id'],
position: Offset(map['x'], map['y']),
size: Size(map['width'], map['height']),
);
}
}

View File

@@ -0,0 +1,836 @@
// ignore: directives_ordering
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_flow_chart/src/dashboard.dart';
import 'package:flutter_flow_chart/src/elements/flow_element.dart';
import 'package:flutter_flow_chart/src/flow_chart_menu.dart';
import 'package:flutter_flow_chart/src/flow_chart_selected_menu.dart';
import 'package:flutter_flow_chart/src/ui/draw_arrow.dart';
import 'package:flutter_flow_chart/src/ui/element_widget.dart';
import 'package:flutter_flow_chart/src/ui/grid_background.dart';
import 'package:flutter_flow_chart/src/ui/segment_handler.dart';
import 'package:uuid/uuid.dart';
/// Main flow chart Widget.
/// It displays the background grid, all the elements and connection lines
class FlowChart<T extends Object> extends StatefulWidget {
FlowChart({
required this.dashboard,
required this.itemWidget,
super.key,
this.itemWidgetTooltip,
this.onElementPressed,
this.onElementSecondaryTapped,
this.onElementLongPressed,
this.onElementSecondaryLongTapped,
this.onDashboardTapped,
this.onDashboardSecondaryTapped,
this.onDashboardLongTapped,
this.onDashboardSecondaryLongTapped,
this.onHandlerPressed,
this.onHandlerSecondaryTapped,
this.onHandlerLongPressed,
this.onHandlerSecondaryLongTapped,
this.onPivotPressed,
this.onPivotSecondaryPressed,
this.onScaleUpdate,
this.onNewConnection,
this.width = 1000,
this.height = 1000,
this.innerMenuWidth = 200,
this.itemWidth = 80,
this.categories = const [],
required this.draggableItemBuilder,
this.onDashboardAlertOpened,
}) {}
final List<String> categories;
final double width;
final double height;
final double innerMenuWidth;
double itemWidth = 80;
double zoom = 1;
final Widget Function(T data) itemWidget;
final Widget Function(T data)? itemWidgetTooltip;
final List<T> Function(String cat) draggableItemBuilder;
final Widget Function(BuildContext constext, Dashboard dash)? onDashboardAlertOpened;
/// callback for tap on dashboard
final void Function(BuildContext context, Offset position)? onDashboardTapped;
/// callback for long tap on dashboard
final void Function(BuildContext context, Offset position)?
onDashboardLongTapped;
/// callback for mouse right click on dashboard
final void Function(BuildContext context, Offset postision)?
onDashboardSecondaryTapped;
/// callback for mouse right click long press on dashboard
final void Function(BuildContext context, Offset position)?
onDashboardSecondaryLongTapped;
/// callback for element pressed
final void Function(
BuildContext context,
Offset position,
FlowElement element,
)? onElementPressed;
/// callback for mouse right click event on an element
final void Function(
BuildContext context,
Offset position,
FlowElement element,
)? onElementSecondaryTapped;
/// callback for element long pressed
final void Function(
BuildContext context,
Offset position,
FlowElement element,
)? onElementLongPressed;
/// callback for right click long press event on an element
final void Function(
BuildContext context,
Offset position,
FlowElement element,
)? onElementSecondaryLongTapped;
/// callback for onclick event of pivot
final void Function(BuildContext context, Pivot pivot)? onPivotPressed;
/// callback for secondary press event of pivot
final void Function(BuildContext context, Pivot pivot)?
onPivotSecondaryPressed;
/// callback for handler pressed
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerPressed;
/// callback for handler right click event
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerSecondaryTapped;
/// callback for handler right click long press event
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerSecondaryLongTapped;
/// callback for handler long pressed
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerLongPressed;
/// callback when adding a new connection
final ConnectionListener? onNewConnection;
/// main dashboard to use
final Dashboard dashboard;
/// Trigger for the scale change
final void Function(double scale)? onScaleUpdate;
@override
State<FlowChart> createState() => FlowChartState<T>();
}
class HoverMenuController {
HoverMenuState? currentState;
void hideSubMenu() {
currentState?.hideSubMenu();
}
}
class HoverMenu extends StatefulWidget {
final Widget title;
final double? width;
final List<Widget> items;
final HoverMenuController? controller;
bool isHovered = false;
HoverMenu({
Key? key,
required this.title,
this.items = const [],
this.width,
this.controller,
}) : super(key: key);
@override
HoverMenuState createState() => HoverMenuState();
}
class HoverMenuState extends State<HoverMenu> {
OverlayEntry? _overlayEntry;
final _focusNode = FocusNode();
@override
void initState() {
super.initState();
_focusNode.addListener(_onFocusChanged);
if (widget.controller != null) {
widget.controller?.currentState = this;
}
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
void _onFocusChanged() {
if (_focusNode.hasFocus) {
_overlayEntry = _createOverlayEntry();
Overlay.of(context).insert(_overlayEntry!);
} else {
_overlayEntry?.remove();
_removeOverlay();
}
}
void _removeOverlay() {
widget.isHovered = false;
}
void hideSubMenu() {
_focusNode.unfocus();
}
@override
Widget build(BuildContext context) {
return TextButton(
style: TextButton.styleFrom(
elevation: 0.0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0)),
splashFactory: NoSplash.splashFactory,
padding: EdgeInsets.zero,
foregroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
),
focusNode: _focusNode,
onHover: (isHovered) {
if (isHovered && !widget.isHovered) {
_focusNode.requestFocus();
isHovered = true;
} else {
_focusNode.unfocus();
widget.isHovered = false;
}
},
onPressed: () {},
child: widget.title,
);
}
OverlayEntry _createOverlayEntry() {
final renderBox = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
return OverlayEntry(
maintainState: true,
builder: (context) => Positioned(
left: offset.dx,
top: offset.dy + size.height,
width: 300,
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0)),
splashFactory: NoSplash.splashFactory,
backgroundColor: Colors.transparent,
),
onPressed: () {},
child: ListView(
padding: EdgeInsets.zero,
shrinkWrap: true,
children: widget.items)),
),
);
}
}
class FlowChartState<T extends Object> extends State<FlowChart> {
List<Draggable<T>> getDraggable(List<T> items) {
List<Draggable<T>> res = [];
double realSize = widget.itemWidth * widget.zoom;
GlobalKey<HoverMenuState> hoverKey = GlobalKey<HoverMenuState>();
for (var e in items) {
res.add(Draggable<T>(
// Data is the value this Draggable stores.
data: e,
onDragStarted: () => hoverKey.currentState?.hideSubMenu(),
childWhenDragging: Opacity(opacity: .5,
child: Padding( padding: const EdgeInsets.all(10),
child: Container( height: realSize - 20, child: widget.itemWidget(e) ))),
feedback: Container( height: realSize, child: widget.itemWidget(e) ),
child: InkWell( mouseCursor: SystemMouseCursors.grab, child: Padding( padding: const EdgeInsets.all(10),
child: widget.itemWidgetTooltip != null ? HoverMenu( key: hoverKey, width: 400, title: Container(
height: realSize - 20, child: widget.itemWidget(e) ),
items: [
Container(child: widget.itemWidgetTooltip!(e)),
]
) : Container(
height: realSize - 20, child: widget.itemWidget(e)
)
) )));
}
if (!widget.dashboard.isOpened && widget.onDashboardAlertOpened != null) {
widget.dashboard.isOpened = true;
Future.delayed(Duration(milliseconds: 1), () => showDialog(
barrierDismissible: false,
context: context, builder: (context) {
return AlertDialog(
titlePadding: EdgeInsets.zero,
insetPadding: EdgeInsets.zero,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(0)),
title: widget.onDashboardAlertOpened!(context, widget.dashboard));
}));
}
widget.dashboard.isOpened = true;
return res;
}
@override
Widget build(BuildContext context) {
/// get dashboard position after first frame is drawn
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
if (mounted) {
final object = context.findRenderObject() as RenderBox?;
if (object != null) {
final translation = object.getTransformTo(null).getTranslation();
final size = object.semanticBounds.size;
final position = Offset(translation.x, translation.y);
widget.dashboard.setDashboardSize(size);
widget.dashboard.setDashboardPosition(position);
}
}
});
// disabling default browser context menu on web
if (kIsWeb) BrowserContextMenu.disableContextMenu();
List<Widget> menuItems = [];
for (var cat in widget.categories) {
menuItems.add(
Container( width: widget.dashboard.isMenu ? widget.innerMenuWidth : 0, margin: const EdgeInsets.only(bottom: 0.3),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Colors.grey, width: .5))),
child: Stack( children: [
widget.dashboard.isMenu && widget.innerMenuWidth < 200 ? Wrap( alignment: WrapAlignment.start,
children: getDraggable(widget.draggableItemBuilder(cat) as List<T>))
: ExpansionTile(
shape: const ContinuousRectangleBorder(side: BorderSide(color: Colors.transparent)),
initiallyExpanded: true,
title: SizedBox(
child : Row( children: [
Padding(padding: const EdgeInsets.only(right: 10),
child: Icon(cat.toUpperCase().contains("DATA") ? Icons.grid_on : Icons.bookmark, color: Colors.grey)),
Flexible(
child: Padding(
padding: const EdgeInsets.only(right: 5),
child: Text(cat.toUpperCase(), overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.black, fontSize: 11, fontWeight: FontWeight.w500))))
])
),
iconColor: Colors.white,
collapsedIconColor: Colors.white,
children: [
Container( width: widget.dashboard.isMenu ? widget.innerMenuWidth : 0, color: Colors.white,
child : Wrap( alignment: WrapAlignment.start,
children: getDraggable(widget.draggableItemBuilder(cat) as List<T>))
)],
)
]
))
);
}
return ClipRect(
child: Stack(
clipBehavior: Clip.none,
children: [
Stack(children: [
// Draw the grid
Container( child: DragTarget<T>(
builder: (
BuildContext context,
List<dynamic> accepted,
List<dynamic> rejected,
) {
return SizedBox(
width: widget.width,
height: widget.height,
child: ChartWidget(
key: widget.dashboard.chartKey,
flowChart: this,
dashboard: widget.dashboard,
onNewConnection: widget.onNewConnection,
onDashboardTapped: widget.onDashboardLongTapped,
onScaleUpdate: widget.onScaleUpdate,
onDashboardSecondaryTapped: widget.onDashboardSecondaryTapped,
onDashboardLongTapped: widget.onDashboardLongTapped,
onDashboardSecondaryLongTapped: widget.onDashboardSecondaryLongTapped,
onElementLongPressed: widget.onElementLongPressed,
onElementSecondaryLongTapped: widget.onElementSecondaryLongTapped,
onElementPressed: widget.onElementPressed,
onElementSecondaryTapped: widget.onElementSecondaryTapped,
onHandlerPressed: widget.onHandlerLongPressed,
onHandlerLongPressed: widget.onHandlerLongPressed,
onPivotSecondaryPressed: widget.onPivotSecondaryPressed,
));
},
onAcceptWithDetails: (DragTargetDetails<T> details) {
var e = details.data;
String newID = const Uuid().v4();
FlowElement el = FlowElement(
id: newID,
position: details.offset,
size: const Size(100, 100),
text: '${widget.dashboard.elements.length}',
handlerSize: 15,
widget: widget.itemWidget(e),
kind: ElementKind.widget,
handlers: [
Handler.bottomCenter,
Handler.topCenter,
Handler.leftCenter,
Handler.rightCenter,
],
);
widget.dashboard.addElement(el);
},
))]
),
widget.dashboard.isMenu ? Positioned(top: 50, child: Container(
height: widget.height - 50,
constraints: BoxConstraints(minWidth: widget.itemWidth),
width: widget.dashboard.isMenu ? widget.innerMenuWidth : 0,
color: Colors.grey.shade300,
child: SingleChildScrollView( child: Column( children: menuItems ) )
)) : Container(),
])
);
}
}
/// Widget to draw interactive connection when the user tap on handlers
class DrawingArrowWidget extends StatefulWidget {
///
const DrawingArrowWidget({required this.style, super.key});
///
final ArrowStyle style;
@override
State<DrawingArrowWidget> createState() => _DrawingArrowWidgetState();
}
class _DrawingArrowWidgetState extends State<DrawingArrowWidget> {
@override
void initState() {
super.initState();
DrawingArrow.instance.addListener(_arrowChanged);
}
@override
void dispose() {
DrawingArrow.instance.removeListener(_arrowChanged);
super.dispose();
}
void _arrowChanged() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
if (DrawingArrow.instance.isZero()) return const SizedBox.shrink();
return CustomPaint(
painter: ArrowPainter(
fromID: DrawingArrow.instance.fromID,
toID: "",
params: DrawingArrow.instance.params,
from: DrawingArrow.instance.from,
to: DrawingArrow.instance.to,
),
);
}
}
class ChartWidget extends StatefulWidget {
ChartWidget ({ Key? key,
required this.flowChart,
this.onElementPressed,
this.onElementSecondaryTapped,
this.onElementLongPressed,
this.onElementSecondaryLongTapped,
this.onDashboardTapped,
this.onDashboardSecondaryTapped,
this.onDashboardLongTapped,
this.onDashboardSecondaryLongTapped,
this.onHandlerPressed,
this.onHandlerSecondaryTapped,
this.onHandlerLongPressed,
this.onHandlerSecondaryLongTapped,
this.onPivotPressed,
this.onPivotSecondaryPressed,
this.onScaleUpdate,
required this.dashboard,
this.onNewConnection,
}) : super(key: key);
FlowChartState flowChart;
final void Function(BuildContext context, Offset position)? onDashboardTapped;
/// callback for long tap on dashboard
final void Function(BuildContext context, Offset position)?
onDashboardLongTapped;
/// callback for mouse right click on dashboard
final void Function(BuildContext context, Offset postision)?
onDashboardSecondaryTapped;
/// callback for mouse right click long press on dashboard
final void Function(BuildContext context, Offset position)?
onDashboardSecondaryLongTapped;
/// callback for element pressed
final void Function(
BuildContext context,
Offset position,
FlowElement element,
)? onElementPressed;
/// callback for mouse right click event on an element
final void Function(
BuildContext context,
Offset position,
FlowElement element,
)? onElementSecondaryTapped;
/// callback for element long pressed
final void Function(
BuildContext context,
Offset position,
FlowElement element,
)? onElementLongPressed;
/// callback for right click long press event on an element
final void Function(
BuildContext context,
Offset position,
FlowElement element,
)? onElementSecondaryLongTapped;
/// callback for onclick event of pivot
final void Function(BuildContext context, Pivot pivot)? onPivotPressed;
/// callback for secondary press event of pivot
final void Function(BuildContext context, Pivot pivot)?
onPivotSecondaryPressed;
/// callback for handler pressed
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerPressed;
/// callback for handler right click event
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerSecondaryTapped;
/// callback for handler right click long press event
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerSecondaryLongTapped;
/// Trigger for the scale change
final void Function(double scale)? onScaleUpdate;
/// callback for handler long pressed
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerLongPressed;
/// callback when adding a new connection
final ConnectionListener? onNewConnection;
final Dashboard dashboard;
@override ChartWidgetState createState() => ChartWidgetState();
}
class ChartWidgetState extends State<ChartWidget> {
bool hoverImportant = false;
final segmentedTension = ValueNotifier<double>(1);
@override
void initState() {
super.initState();
widget.dashboard.addListener(_elementChanged);
if (widget.onScaleUpdate != null) {
widget.dashboard.gridBackgroundParams.addOnScaleUpdateListener(
widget.onScaleUpdate!,
);
}
if (widget.onNewConnection != null) {
widget.dashboard.addConnectionListener(widget.onNewConnection!);
}
}
@override
void dispose() {
widget.dashboard.removeListener(_elementChanged);
if (widget.onScaleUpdate != null) {
widget.dashboard.gridBackgroundParams.removeOnScaleUpdateListener(
widget.onScaleUpdate!,
);
}
super.dispose();
}
void _elementChanged() {
if (mounted) setState(() {});
}
double _oldScaleUpdateDelta = 0;
@override Widget build(BuildContext context) {
final gridKey = GlobalKey();
var tapDownPos = Offset.zero;
var secondaryTapDownPos = Offset.zero;
return Stack( children: [
Positioned.fill(
child: GestureDetector(
onTapDown: (details) {
hoverImportant = false;
tapDownPos = details.localPosition;
for (var arr in widget.dashboard.arrows) {
if (arr.isLine(tapDownPos)) {
hoverImportant = true;
arr.isSelected = !arr.isSelected;
for (var sel in widget.dashboard.elements) {
sel.isSelected = false;
}
Future.delayed(Duration(seconds: 1), () {
widget.dashboard.selectedMenuKey.currentState?.setState(() {});
DrawingArrow.instance.notifyListeners();
});
}
}
if (!hoverImportant) {
for (var sel in widget.dashboard.elements) { sel.isSelected = false; }
for (var sel in widget.dashboard.arrows) { sel.isSelected = false; }
Future.delayed(Duration(seconds: 1), () {
widget.dashboard.selectedMenuKey.currentState?.setState(() {});
DrawingArrow.instance.notifyListeners();
});
}
setState(() {});
},
onSecondaryTapDown: (details) {
secondaryTapDownPos = details.localPosition;
},
onTap: widget.onDashboardTapped == null
? null
: () => widget.onDashboardTapped!(
gridKey.currentContext!,
tapDownPos,
),
onLongPress: widget.onDashboardLongTapped == null
? null
: () => widget.onDashboardLongTapped!(
gridKey.currentContext!,
tapDownPos,
),
onSecondaryTap: () {
widget.onDashboardSecondaryTapped?.call(
gridKey.currentContext!,
secondaryTapDownPos,
);
},
onSecondaryLongPress: () {
widget.onDashboardSecondaryLongTapped?.call(
gridKey.currentContext!,
secondaryTapDownPos,
);
},
onScaleUpdate: (details) {
if (details.scale != 1) {
widget.dashboard.setZoomFactor(
details.scale + _oldScaleUpdateDelta,
focalPoint: details.focalPoint,
);
}
widget.dashboard.setDashboardPosition(
widget.dashboard.position + details.focalPointDelta,
);
for (var i = 0; i < widget.dashboard.elements.length; i++) {
widget.dashboard.elements[i].position += details.focalPointDelta;
for (final conn in widget.dashboard.elements[i].next) {
for (final pivot in conn.pivots) {
pivot.pivot += details.focalPointDelta;
}
}
}
widget.dashboard.gridBackgroundParams.offset = details.focalPointDelta;
setState(() {});
},
onScaleEnd: (details) {
_oldScaleUpdateDelta = widget.dashboard.zoomFactor - 1;
},
child: GridBackground(
key: gridKey,
params: widget.dashboard.gridBackgroundParams,
),
),
),
// Draw elements
for (int i = 0; i < widget.dashboard.elements.length; i++)
ElementWidget(
key: UniqueKey(),
dashboard: widget.dashboard,
element: widget.dashboard.elements.elementAt(i),
onElementPressed: widget.onElementPressed == null
? null
: (context, position) => widget.onElementPressed!(
context,
position,
widget.dashboard.elements.elementAt(i),
),
onElementSecondaryTapped: widget.onElementSecondaryTapped == null
? null
: (context, position) => widget.onElementSecondaryTapped!(
context,
position,
widget.dashboard.elements.elementAt(i),
),
onElementLongPressed: widget.onElementLongPressed == null
? null
: (context, position) => widget.onElementLongPressed!(
context,
position,
widget.dashboard.elements.elementAt(i),
),
onElementSecondaryLongTapped:
widget.onElementSecondaryLongTapped == null
? null
: (context, position) =>
widget.onElementSecondaryLongTapped!(
context,
position,
widget.dashboard.elements.elementAt(i),
),
onHandlerPressed: widget.onHandlerPressed == null
? null
: (context, position, handler, element) => widget
.onHandlerPressed!(context, position, handler, element),
onHandlerSecondaryTapped: widget.onHandlerSecondaryTapped == null
? null
: (context, position, handler, element) =>
widget.onHandlerSecondaryTapped!(
context,
position,
handler,
element,
),
onHandlerLongPressed: widget.onHandlerLongPressed == null
? null
: (context, position, handler, element) =>
widget.onHandlerLongPressed!(
context,
position,
handler,
element,
),
onHandlerSecondaryLongTapped:
widget.onHandlerSecondaryLongTapped == null
? null
: (context, position, handler, element) =>
widget.onHandlerSecondaryLongTapped!(
context,
position,
handler,
element,
),
),
// Draw arrows
for (int i = 0; i < widget.dashboard.elements.length; i++)
for (int n = 0; n < widget.dashboard.elements[i].next.length; n++)
DrawArrow(
flow: this,
key: UniqueKey(),
index: n,
srcElement: widget.dashboard.elements[i],
destElement: widget.dashboard.elements[widget.dashboard.findElementIndexById(
widget.dashboard.elements[i].next[n].destElementId,
)],
arrowParams: widget.dashboard.elements[i].next[n].arrowParams,
pivots: widget.dashboard.elements[i].next[n].pivots,
),
// drawing segment handlers
for (int i = 0; i < widget.dashboard.elements.length; i++)
for (int n = 0; n < widget.dashboard.elements[i].next.length; n++)
if (widget.dashboard.elements[i].next[n].arrowParams.style == ArrowStyle.segmented)
for (int j = 0; j < widget.dashboard.elements[i].next[n].pivots.length; j++)
SegmentHandler(
key: UniqueKey(),
pivot: widget.dashboard.elements[i].next[n].pivots[j],
dashboard: widget.dashboard,
onPivotPressed: widget.onPivotPressed,
onPivotSecondaryPressed: widget.onPivotSecondaryPressed,
),
// user drawing when connecting elements
DrawingArrowWidget(style: widget.dashboard.defaultArrowStyle),
Positioned(top: 0, right: 0, child: FlowChartMenu(
key: widget.dashboard.chartMenuKey,
chart: this,
dashboard: widget.dashboard,
width: MediaQuery.of(context).size.width)
),
widget.dashboard.isInfo ? Positioned(top: 50, right: 0, child:
FlowChartSelectedMenu(key: widget.dashboard.selectedMenuKey, chart: this,
dashboard: widget.dashboard, height: MediaQuery.of(context).size.height - 100)
) : Container()
],
);
}
}

View File

@@ -0,0 +1,264 @@
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/flutter_flow_chart.dart';
import 'package:dotted_line/dotted_line.dart';
enum DisplayEnum {
MENU,
INFO
}
class FlowChartMenu extends StatefulWidget {
ChartWidgetState chart;
Dashboard dashboard;
double width = 100;
FlowChartMenu ({ super.key, required this.chart, required this.dashboard, this.width = 100 });
@override FlowChartMenuState createState() => FlowChartMenuState();
}
class FlowChartMenuState extends State<FlowChartMenu> {
@override Widget build(BuildContext context) {
GlobalKey<FormFieldState> zoomKey = GlobalKey<FormFieldState>();
return Container( // SHORTCUT
width: widget.width,
height: 50,
padding: EdgeInsets.symmetric(horizontal: 20),
color: const Color.fromRGBO(38, 166, 154, 1),
child: Row( children : [ Expanded(flex: 2, child: Row( children: [
widget.chart.widget.flowChart.widget.onDashboardAlertOpened == null ? Container() : Container(
decoration: BoxDecoration(
border: Border(right: BorderSide(color: Colors.white, width: 1))
),
child: Row( children: [
Tooltip( message: "open file", child:Container( child:
Padding( padding: EdgeInsets.only(right: 15),
child: InkWell( mouseCursor: SystemMouseCursors.click,
onTap: () {
showDialog(
barrierDismissible: false,
context: context, builder: (context) {
return AlertDialog(
titlePadding: EdgeInsets.zero,
insetPadding: EdgeInsets.zero,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(0)),
title: widget.chart.widget.flowChart.widget.onDashboardAlertOpened!(
context, widget.dashboard));
});
},
child: Icon(Icons.folder_open, color: Colors.white))))),
])),
InkWell( mouseCursor: SystemMouseCursors.click, child: Container(
decoration: BoxDecoration(
border: Border(right: BorderSide(color: Colors.white, width: 1))
),
child: Padding( padding: EdgeInsets.symmetric(horizontal: 10),
child: PopupMenuButton<DisplayEnum>(
child:
Row( children: [ Icon(Icons.fullscreen, color: Colors.white), Icon(Icons.arrow_drop_down, size: 10, color: Colors.white) ]),
initialValue: null,
onSelected: (DisplayEnum value) {
if (value == DisplayEnum.MENU) { widget.dashboard.isMenu = !widget.dashboard.isMenu; }
if (value == DisplayEnum.INFO) { widget.dashboard.isInfo = !widget.dashboard.isInfo; }
widget.chart.widget.flowChart.setState(() {});
},
tooltip: "display",
itemBuilder: (BuildContext context) => <PopupMenuEntry<DisplayEnum>>[
PopupMenuItem<DisplayEnum>(
value: DisplayEnum.MENU,
child: Row( children: [
Icon(widget.dashboard.isMenu ? Icons.remove_red_eye : Icons.remove_red_eye_outlined),
Padding( padding: EdgeInsets.only(left: 10),
child: Text(widget.dashboard.isMenu ? 'hide menu' : 'show menu', textAlign: TextAlign.center,))
]),
),
PopupMenuItem<DisplayEnum>(
value: DisplayEnum.INFO,
child: Row( children: [
Icon(widget.dashboard.isMenu ? Icons.remove_red_eye : Icons.remove_red_eye_outlined),
Padding( padding: EdgeInsets.only(left: 10),
child: Text(widget.dashboard.isInfo ? 'hide info' : 'show info', textAlign: TextAlign.center,))
]),
),
]
),)
)
),
InkWell( mouseCursor: SystemMouseCursors.click, child: Container(
decoration: BoxDecoration(
border: Border(right: BorderSide(color: Colors.white, width: 1))
),
child: Padding( padding: EdgeInsets.symmetric(horizontal: 10),
child: PopupMenuButton<double>(
child:
Row( children: [ Text("${(widget.dashboard.getZoomFactor() * 100).toInt()}% ",
style: TextStyle(color: Colors.white),), Icon(Icons.arrow_drop_down, size: 10, color: Colors.white) ]),
initialValue: [0.25, .5, 75, 1, 2, 3, 4].contains(widget.dashboard.currentZoom) ? widget.dashboard.currentZoom : null,
onSelected: (double value) {
widget.dashboard.setZoomFactor(value);
},
tooltip: "custom zoom",
itemBuilder: (BuildContext context) => <PopupMenuEntry<double>>[
PopupMenuItem<double>(
enabled: false,
padding: EdgeInsets.all(0),
value: widget.dashboard.currentZoom,
child: Wrap( alignment: WrapAlignment.center, children: [
Padding( padding: EdgeInsets.only(left: 10, top: 10, bottom: 10) , child: TextFormField(
key: zoomKey,
cursorColor: const Color.fromARGB(38, 166, 154, 1),
onChanged: (value) { },
validator: (value) {
try {
double.parse(value ?? "");
return null;
} catch (e) { return "Invalid number"; }
},
decoration: const InputDecoration(
constraints: BoxConstraints(maxWidth: 100, minWidth: 50, minHeight: 50, maxHeight: 50),
hintText: "zoom...",
fillColor: Colors.white,
contentPadding: EdgeInsets.only(left: 20, right: 20, top: 5, bottom: 10),
filled: true,
hintStyle: TextStyle(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.w300
),
border: InputBorder.none,
enabledBorder: InputBorder.none
)
)),
Tooltip(
message: 'apply zoom',
child:InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: () {
if (zoomKey.currentState != null && zoomKey.currentState!.validate()) {
widget.dashboard.setZoomFactor(double.parse(zoomKey.currentState?.value) / 100);
}
},
child: Container(
margin: EdgeInsets.only(top: 10),
width: 48,
height: 48,
color: Colors.black,
child: Icon(Icons.check, color: Colors.white)
)
)
)
],
),
),
const PopupMenuItem<double>(
value: 0.25,
child: Text('25%'),
),
const PopupMenuItem<double>(
value: 0.5,
child: Text('50%'),
),
const PopupMenuItem<double>(
value: 0.75,
child: Text('75%'),
),
const PopupMenuItem<double>(
value: 1,
child: Text('100%'),
),
const PopupMenuItem<double>(
value: 1.25,
child: Text('125%'),
),
const PopupMenuItem<double>(
value: 1.50,
child: Text('150%'),
),
const PopupMenuItem<double>(
value: 2,
child: Text('200%'),
),
const PopupMenuItem<double>(
value: 3,
child: Text('300%'),
),
const PopupMenuItem<double>(
value: 4,
child: Text('400%'),
),
],
)
),)),
Container(
decoration: BoxDecoration(
border: Border(right: BorderSide(color: Colors.white, width: 1))
),
child: Row( children: [
Tooltip( message: "zoom in", child: Container( child: Padding( padding: EdgeInsets.symmetric(horizontal: 10),
child: InkWell( mouseCursor: SystemMouseCursors.click,
onTap: () {
widget.dashboard.setZoomFactor(widget.dashboard.zoomFactor + 0.1);
},
child: Icon(Icons.zoom_in, color: Colors.white))))),
Tooltip( message: "zoom out", child:Container( child: Padding( padding: EdgeInsets.symmetric(horizontal: 10),
child: InkWell( mouseCursor: SystemMouseCursors.click,
onTap: () {
widget.dashboard.setZoomFactor(widget.dashboard.zoomFactor - 0.1);
},
child: Icon(Icons.zoom_out, color: Colors.white))))),
])),
Container(
decoration: BoxDecoration(
border: Border(right: BorderSide(color: Colors.white, width: 1))
),
child: Row( children: [
Padding( padding: EdgeInsets.symmetric(horizontal: 10),
child: InkWell(
onTap: () {
if (widget.dashboard.canBack()) {
widget.dashboard.back();
}
},
mouseCursor: !widget.dashboard.canBack() ? MouseCursor.defer : SystemMouseCursors.click,
child: Icon(Icons.undo, color: !widget.dashboard.canBack() ? Colors.grey.shade300 : Colors.white)),),
Padding( padding: EdgeInsets.symmetric(horizontal: 10),
child: InkWell(
onTap: () {
if (widget.dashboard.canForward()) {
widget.dashboard.forward();
}
},
mouseCursor: !widget.dashboard.canForward() ? MouseCursor.defer : SystemMouseCursors.click, child: Icon(Icons.redo,
color: !widget.dashboard.canForward() ? Colors.grey.shade300 : Colors.white))),
])),
Padding( child: Text("file opened : ${widget.dashboard.name}", overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.white, fontSize: 14), textAlign: TextAlign.center),
padding: EdgeInsets.symmetric(horizontal: 20)),
])),
])
);
}
}
class MySeparator extends StatelessWidget {
double width = 1; double dashWidth = 10; double dashSpace = 10;
MySeparator({Key? key, this.width = 1, this.dashSpace = 10, this.dashWidth = 10,
this.height = 1, this.color = Colors.black})
: super(key: key);
final double height;
final Color color;
@override
Widget build(BuildContext context) {
return Container( width: width, child: dashSpace == 0 ?
Divider( thickness: 2, color: color )
: DottedLine(
dashLength: dashWidth,
dashGapLength: dashSpace,
lineThickness: 2,
dashColor: color
)
);
}
}

View File

@@ -0,0 +1,520 @@
import 'package:flutter/material.dart';
import 'package:dotted_line/dotted_line.dart';
import 'package:flutter_flow_chart/src/ui/draw_arrow.dart';
import 'package:flutter_flow_chart/flutter_flow_chart.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:number_text_input_formatter/number_text_input_formatter.dart';
class FlowChartSelectedMenu extends StatefulWidget {
ChartWidgetState chart;
Dashboard dashboard;
double height = 100;
FlowChartSelectedMenu ({ super.key, required this.chart, required this.dashboard, this.height = 100 });
@override FlowChartSelectedMenuState createState() => FlowChartSelectedMenuState();
}
class FlowChartSelectedMenuState extends State<FlowChartSelectedMenu> {
@override Widget build(BuildContext context) {
return Container( // SHORTCUT
width: 200,
height: widget.height,
color: Colors.grey.shade300,
child: Column( children: [
Container( padding: EdgeInsets.all(10), width: 200, height: 60,
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Colors.grey, width: 1))),
child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [
Text("STYLE ${widget.dashboard.elementSelected.isNotEmpty ? "ELEMENT" : "ARROW"}", style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
Text("<${widget.dashboard.arrowsSelected.isEmpty && widget.dashboard.elementSelected.isEmpty ? "general" : "selected"}>", style: TextStyle(fontSize: 12), textAlign: TextAlign.center),
])),
Container( width: 200, height: widget.height - 60, child: SingleChildScrollView( child: Column( children: [
widget.dashboard.elementSelected.isNotEmpty ? Container() : Container( padding: EdgeInsets.symmetric(horizontal: 10, vertical: 20),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Colors.grey, width: 1))),
child: Column( children: [
Row( children: [
InkWell( mouseCursor: SystemMouseCursors.click, child: Container(
child: Padding( padding: EdgeInsets.symmetric(horizontal: 10),
child: PopupMenuButton<ArrowDash>(
tooltip: "line defaults",
constraints: BoxConstraints(maxWidth: 100),
child: Row( children: [
MySeparator(
width: 45,
dashWidth: widget.dashboard.defaultDashWidth,
dashSpace: widget.dashboard.defaultDashSpace,
color: Colors.black
),
Container(height: 25, width: 10),
Icon(Icons.arrow_drop_down, size: 10, color: Colors.black) ]),
initialValue: null,
onSelected: (ArrowDash value) {
if (widget.dashboard.elementSelected.isEmpty) {
for(var sel in widget.dashboard.arrowsSelected) {
sel.params.dashSpace = spaceArrowDash(value);
sel.params.dashWidth = widthArrowDash(value);
}
widget.dashboard.chartKey.currentState?.setState(() { });
}
widget.dashboard.defaultDashSpace = spaceArrowDash(value);
widget.dashboard.defaultDashWidth = widthArrowDash(value);
setState(() {});
},
itemBuilder: (BuildContext context) => <PopupMenuEntry<ArrowDash>>[
PopupMenuItem<ArrowDash>(
value: ArrowDash.line,
child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [
MySeparator(width: 50, dashWidth: widthArrowDash(ArrowDash.line),
dashSpace: spaceArrowDash(ArrowDash.line),)
]),
),
PopupMenuItem<ArrowDash>(
value: ArrowDash.largeDash,
child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [
MySeparator(width: 50, dashWidth: widthArrowDash(ArrowDash.largeDash), dashSpace: spaceArrowDash(ArrowDash.largeDash),)
]),
),
PopupMenuItem<ArrowDash>(
value: ArrowDash.mediumDash,
child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [
MySeparator(width: 50, dashWidth: widthArrowDash(ArrowDash.mediumDash), dashSpace: spaceArrowDash(ArrowDash.mediumDash),)
]),
),
PopupMenuItem<ArrowDash>(
value: ArrowDash.smallDash,
child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [
MySeparator(width: 50, dashWidth: widthArrowDash(ArrowDash.smallDash), dashSpace: spaceArrowDash(ArrowDash.smallDash),)
]),
),
PopupMenuItem<ArrowDash>(
value: ArrowDash.heavyDotted,
child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [
MySeparator(width: 50, dashWidth: widthArrowDash(ArrowDash.heavyDotted), dashSpace: spaceArrowDash(ArrowDash.heavyDotted),)
]),
),
PopupMenuItem<ArrowDash>(
value: ArrowDash.mediumDotted,
child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [
MySeparator(width: 50, dashWidth: widthArrowDash(ArrowDash.mediumDotted), dashSpace: spaceArrowDash(ArrowDash.mediumDotted),)
]),
),
PopupMenuItem<ArrowDash>(
value: ArrowDash.lightDotted,
child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [
MySeparator(width: 50, dashWidth: widthArrowDash(ArrowDash.lightDotted), dashSpace: spaceArrowDash(ArrowDash.lightDotted),)
]),
),
]
),
)
)
),
PopupMenuButton<void>(
tooltip: "color picker",
constraints: BoxConstraints(maxWidth: 664),
child: Row( children: [
Container(width: 15, height: 15, color: widget.dashboard.defaultColor),
Container(height: 25, width: 5),
Icon(Icons.arrow_drop_down, size: 10, color: Colors.black) ]),
initialValue: null,
onSelected: (void value) {},
itemBuilder: (BuildContext context) => <PopupMenuEntry<void>>[
PopupMenuItem<void>(
child: ColorPicker(pickerColor: Colors.black, onColorChanged: (value) {
if (widget.dashboard.elementSelected.isEmpty) {
for(var sel in widget.dashboard.arrowsSelected) { sel.params.color = value; }
widget.dashboard.chartKey.currentState?.setState(() { });
}
setState(() { widget.dashboard.defaultColor = value; });
},),
),
]
),
Tooltip( message: "stroke width",
child: Container(
margin: EdgeInsets.only(left: 10),
width: 45, height: 25,
child: TextFormField( textAlign: TextAlign.center,
readOnly: widget.dashboard.defaultDashWidth <= 0,
initialValue: "${widget.dashboard.defaultStroke}",
onChanged: (value) {
if (widget.dashboard.elementSelected.isEmpty) {
for(var sel in widget.dashboard.arrowsSelected) { sel.params.thickness = double.parse(value); }
widget.dashboard.chartKey.currentState?.setState(() { });
}
setState(() { widget.dashboard.defaultStroke = double.parse(value); });
},
style: TextStyle(fontSize: 12),
decoration: InputDecoration(
fillColor: Colors.white,
filled: true,
labelText: "stroke",
labelStyle: TextStyle(fontSize: 10),
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 5),
),
inputFormatters: [
NumberTextInputFormatter(
integerDigits: 100,
decimalDigits: 1,
maxValue: '99',
decimalSeparator: '.',
groupSeparator: ',',
allowNegative: false,
overrideDecimalPoint: false,
insertDecimalPoint: false,
insertDecimalDigits: false,
),
],
keyboardType: TextInputType.number,
)))
]),
Row(children: [
InkWell( mouseCursor: SystemMouseCursors.click, child: Container(
child: Padding( padding: EdgeInsets.only(left: 10, top: 10, right: 10),
child: PopupMenuButton<ArrowStyle>(
child:
Row( children: [ Icon(widget.dashboard.defaultArrowStyle == ArrowStyle.segmented ? Icons.turn_slight_left : widget.dashboard.defaultArrowStyle == ArrowStyle.curve ? Icons.roundabout_left : Icons.turn_sharp_left_outlined
, color: Colors.black),
Icon(Icons.arrow_drop_down, size: 10, color: Colors.black) ]),
initialValue: null,
onSelected: (ArrowStyle value) {
if (widget.dashboard.elementSelected.isEmpty) {
for(var sel in widget.dashboard.arrowsSelected) { sel.params.style = value; }
widget.dashboard.chartKey.currentState?.setState(() { });
}
widget.dashboard.defaultArrowStyle = value;
setState(() {});
},
tooltip: "line styles",
itemBuilder: (BuildContext context) => <PopupMenuEntry<ArrowStyle>>[
PopupMenuItem<ArrowStyle>(
value: ArrowStyle.segmented,
child: Row( children: [
Icon(Icons.turn_slight_left),
Padding( padding: EdgeInsets.only(left: 10),
child: Text('straight', textAlign: TextAlign.center,))
]),
),
PopupMenuItem<ArrowStyle>(
value: ArrowStyle.curve,
child: Row( children: [
Icon(Icons.roundabout_left),
Padding( padding: EdgeInsets.only(left: 10),
child: Text('curved', textAlign: TextAlign.center,))
]),
),
PopupMenuItem<ArrowStyle>(
value: ArrowStyle.rectangular,
child: Row( children: [
Icon(Icons.turn_sharp_left_outlined),
Padding( padding: EdgeInsets.only(left: 10),
child: Text('rectangular', textAlign: TextAlign.center,))
]),
),
]
)
)
)
),
Tooltip( message: "space dash",
child: Container(
margin: EdgeInsets.only(top: 10),
width: 105 / 2, height: 25,
child: TextFormField( textAlign: TextAlign.center,
readOnly: widget.dashboard.defaultDashWidth <= 0,
initialValue: "${widget.dashboard.defaultDashWidth}",
onChanged: (value) {
if (widget.dashboard.elementSelected.isEmpty) {
for(var sel in widget.dashboard.arrowsSelected) { sel.params.dashWidth = double.parse(value); }
widget.dashboard.chartKey.currentState?.setState(() { });
}
setState(() { widget.dashboard.defaultDashWidth = double.parse(value); });
},
style: TextStyle(fontSize: 12),
decoration: InputDecoration(
fillColor: widget.dashboard.defaultDashWidth <= 0 ? Colors.grey.shade200 : Colors.white,
filled: true,
labelText: "dash",
labelStyle: TextStyle(fontSize: 10),
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 5),
),
inputFormatters: [
NumberTextInputFormatter(
integerDigits: 100,
decimalDigits: 0,
maxValue: '99',
decimalSeparator: '.',
groupSeparator: ',',
allowNegative: false,
overrideDecimalPoint: false,
insertDecimalPoint: false,
insertDecimalDigits: false,
),
],
keyboardType: TextInputType.number,
))),
Tooltip( message: "space width",
child: Container(
margin: EdgeInsets.only(left: 10, top: 10),
width: 105 / 2, height: 25,
child: TextFormField( textAlign: TextAlign.center,
initialValue: "${widget.dashboard.defaultDashSpace}",
onChanged: (value) {
if (widget.dashboard.elementSelected.isEmpty) {
for(var sel in widget.dashboard.arrowsSelected) { sel.params.dashSpace = double.parse(value); }
widget.dashboard.chartKey.currentState?.setState(() { });
}
setState(() { widget.dashboard.defaultDashSpace = double.parse(value); });
},
style: TextStyle(fontSize: 12),
decoration: InputDecoration(
fillColor: widget.dashboard.defaultDashWidth <= 0 ? Colors.grey.shade200 : Colors.white,
filled: true,
labelText: "space",
labelStyle: TextStyle(fontSize: 10),
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 5),
),
inputFormatters: [
NumberTextInputFormatter(
integerDigits: 100,
decimalDigits: 0,
maxValue: '99',
decimalSeparator: '.',
groupSeparator: ',',
allowNegative: false,
overrideDecimalPoint: false,
insertDecimalPoint: false,
insertDecimalDigits: false,
),
],
keyboardType: TextInputType.number,
)))
]),
])),
widget.dashboard.elementSelected.isNotEmpty ? Container() : Container( padding: EdgeInsets.only(left: 10, right: 10, bottom: 20, top: 15),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Colors.grey, width: 1))),
child: Column( children: [
Row( mainAxisAlignment: MainAxisAlignment.center, children : [
InkWell( mouseCursor: SystemMouseCursors.click, child: Container(
child: Padding( padding: EdgeInsets.symmetric(horizontal: 10),
child: PopupMenuButton<ArrowDirection>(
child:
Row( children: [
Icon(widget.dashboard.defaultArrowDirection == ArrowDirection.forward ? Icons.arrow_forward : widget.dashboard.defaultArrowDirection == ArrowDirection.backward ? Icons.arrow_back : Icons.sync_alt_outlined, color: Colors.black),
Padding( padding: EdgeInsets.symmetric(horizontal: 10),
child: Text(widget.dashboard.defaultArrowDirection == ArrowDirection.forward ? 'forward' : widget.dashboard.defaultArrowDirection == ArrowDirection.backward ? 'backward' : 'bidirectionnal')),
Icon(Icons.arrow_drop_down, size: 10, color: Colors.black) ]),
initialValue: null,
onSelected: (ArrowDirection value) {
if (widget.dashboard.elementSelected.isEmpty) {
for(var sel in widget.dashboard.arrowsSelected) { sel.params.direction = value; }
widget.dashboard.chartKey.currentState?.setState(() { });
}
widget.dashboard.defaultArrowDirection = value;
setState(() {});
},
tooltip: widget.dashboard.defaultArrowDirection == ArrowDirection.forward ? 'forward' : widget.dashboard.defaultArrowDirection == ArrowDirection.backward ? 'backward' : 'bidirectionnal',
itemBuilder: (BuildContext context) => <PopupMenuEntry<ArrowDirection>>[
PopupMenuItem<ArrowDirection>(
value: ArrowDirection.forward,
child: Row( children: [
Icon(Icons.arrow_forward),
Padding( padding: EdgeInsets.only(left: 10),
child: Text('forward', textAlign: TextAlign.center,))
]),
),
PopupMenuItem<ArrowDirection>(
value: ArrowDirection.backward,
child: Row( children: [
Icon(Icons.arrow_back),
Padding( padding: EdgeInsets.only(left: 10),
child: Text('curved', textAlign: TextAlign.center,))
]),
),
PopupMenuItem<ArrowDirection>(
value: ArrowDirection.bidirectionnal,
child: Row( children: [
Icon(Icons.sync_alt_outlined),
Padding( padding: EdgeInsets.only(left: 10),
child: Text('bidirectionnal', textAlign: TextAlign.center,))
]),
),
]
),)
)
),
]),
Row(children: [
Tooltip( message: "forward size",
child: Container(
margin: EdgeInsets.only(left: 10, top: 10),
width: 135, height: 25,
child: TextFormField( textAlign: TextAlign.center,
initialValue: "${widget.dashboard.defaultForwardWidth}",
onChanged: (value) {
if (widget.dashboard.elementSelected.isEmpty) {
for(var sel in widget.dashboard.arrowsSelected) {
try {
sel.params.forwardWidth = double.parse(value);
} catch(e) {
sel.params.forwardWidth = 0;
}
}
widget.dashboard.chartKey.currentState?.setState(() { });
}
setState(() {
try {
widget.dashboard.defaultForwardWidth = double.parse(value);
} catch(e) {
widget.dashboard.defaultForwardWidth = 0;
}
});
},
style: TextStyle(fontSize: 12),
decoration: InputDecoration(
fillColor: widget.dashboard.defaultDashWidth <= 0 ? Colors.grey.shade200 : Colors.white,
filled: true,
labelText: "forward size",
labelStyle: TextStyle(fontSize: 10),
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 5),
),
inputFormatters: [
NumberTextInputFormatter(
integerDigits: 50,
decimalDigits: 0,
maxValue: '50',
decimalSeparator: '.',
groupSeparator: ',',
allowNegative: false,
overrideDecimalPoint: false,
insertDecimalPoint: false,
insertDecimalDigits: false,
),
],
keyboardType: TextInputType.number,
))),
Padding( padding: EdgeInsets.only(top: 10, left: 5), child: Icon(Icons.arrow_forward, color: Colors.black))
]),
Row(children: [
Tooltip( message: "back size",
child: Container(
margin: EdgeInsets.only(left: 10, top: 10),
width: 135, height: 25,
child: TextFormField( textAlign: TextAlign.center,
initialValue: "${widget.dashboard.defaultBackWidth}",
onChanged: (value) {
if (widget.dashboard.elementSelected.isEmpty) {
for(var sel in widget.dashboard.arrowsSelected) {
try {
sel.params.backwardWidth = double.parse(value);
} catch(e) {
sel.params.backwardWidth = 0;
}
}
widget.dashboard.chartKey.currentState?.setState(() { });
}
setState(() {
try {
widget.dashboard.defaultBackWidth = double.parse(value);
} catch(e) {
widget.dashboard.defaultBackWidth = 0;
}
});
},
style: TextStyle(fontSize: 12),
decoration: InputDecoration(
fillColor: widget.dashboard.defaultDashWidth <= 0 ? Colors.grey.shade200 : Colors.white,
filled: true,
labelText: "back size",
labelStyle: TextStyle(fontSize: 10),
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 5),
),
inputFormatters: [
NumberTextInputFormatter(
integerDigits: 50,
decimalDigits: 0,
maxValue: '50',
decimalSeparator: '.',
groupSeparator: ',',
allowNegative: false,
overrideDecimalPoint: false,
insertDecimalPoint: false,
insertDecimalDigits: false,
),
],
keyboardType: TextInputType.number,
))),
Padding( padding: EdgeInsets.only(top: 10, left: 5), child: Icon(Icons.arrow_back, color: Colors.black))
])
])),
widget.dashboard.arrowsSelected.isNotEmpty || widget.dashboard.elementSelected.isNotEmpty ? Container(
width: 200,
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Colors.grey, width: 1))),
child: Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
Tooltip( message: "remove",
child: InkWell( mouseCursor: SystemMouseCursors.click,
onTap: () {
widget.dashboard.arrows.removeWhere((element) {
if (element.isSelected && element.elementIndex != null && element.connIndex != null) {
widget.dashboard.elements[element.elementIndex!].next.removeAt(element.connIndex!);
}
return element.isSelected;
});
widget.dashboard.elements.removeWhere((element) => element.isSelected);
Future.delayed(Duration(milliseconds: 100), () {
widget.dashboard.chartKey.currentState?.setState(() { });
});
}, child: Container( margin: EdgeInsets.all(10),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(5), border: Border.all(color: Colors.black, width: 1)),
width: 140, height: 30,
child: Icon(Icons.delete_outline, color: Colors.black),
))
),
Tooltip( message: "copy",
child: InkWell( mouseCursor: SystemMouseCursors.click,
onTap: () {
for (var sel in widget.dashboard.elementSelected) {
widget.dashboard.elements.add(FlowElement.fromMap(sel.toMap()));
widget.dashboard.elements.last.position += Offset(50, 50);
}
Future.delayed(Duration(milliseconds: 100), () {
widget.dashboard.chartKey.currentState?.setState(() { });
});
}, child: Container( margin: EdgeInsets.all(10),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(5), border: Border.all(color: Colors.black, width: 1)),
width: 140, height: 30,
child: Icon(Icons.copy, color: Colors.black),
))
),
])
) : Container()
])))
])
);
}
}
class MySeparator extends StatelessWidget {
double width = 1; double dashWidth = 10; double dashSpace = 10;
MySeparator({Key? key, this.width = 1, this.dashSpace = 10, this.dashWidth = 10,
this.height = 1, this.color = Colors.black})
: super(key: key);
final double height;
final Color color;
@override
Widget build(BuildContext context) {
return Container( width: width, child: dashSpace == 0 ?
Divider( thickness: 2, color: color )
: DottedLine(
dashLength: dashWidth,
dashGapLength: dashSpace,
lineThickness: 2,
dashColor: color
)
);
}
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/flutter_flow_chart.dart';
class AnyWidget extends StatelessWidget {
///
const AnyWidget({
required this.element,
super.key,
});
///
final FlowElement element;
@override
Widget build(BuildContext context) {
return SizedBox(
width: element.size.width,
height: element.size.height,
child: element.widget,
);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/src/elements/flow_element.dart';
import 'package:flutter_flow_chart/src/objects/element_text_widget.dart';
/// A kind of element
class DiamondWidget extends StatelessWidget {
///
const DiamondWidget({
required this.element,
super.key,
});
///
final FlowElement element;
@override
Widget build(BuildContext context) {
return SizedBox(
width: element.size.width,
height: element.size.height,
child: Stack(
children: [
CustomPaint(
size: element.size,
painter: _DiamondPainter(
element: element,
),
),
ElementTextWidget(element: element),
],
),
);
}
}
class _DiamondPainter extends CustomPainter {
_DiamondPainter({
required this.element,
});
final FlowElement element;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
final path = Path();
paint
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.fill
..color = element.backgroundColor;
path
..moveTo(size.width / 2, 0)
..lineTo(size.width, size.height / 2)
..lineTo(size.width / 2, size.height)
..lineTo(0, size.height / 2)
..close();
if (element.elevation > 0.01) {
canvas.drawShadow(
path.shift(Offset(element.elevation, element.elevation)),
Colors.black,
element.elevation,
true,
);
}
canvas.drawPath(path, paint);
paint
..strokeWidth = element.borderThickness
..color = element.borderColor
..style = PaintingStyle.stroke;
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/flutter_flow_chart.dart';
/// Common widget for the element text
class ElementTextWidget extends StatelessWidget {
///
const ElementTextWidget({
required this.element,
super.key,
});
///
final FlowElement element;
@override
Widget build(BuildContext context) {
return Align(
child: Text(
element.text,
textAlign: TextAlign.center,
style: TextStyle(
color: element.textColor,
fontSize: element.textSize,
fontWeight: element.textIsBold ? FontWeight.bold : FontWeight.normal,
fontFamily: element.fontFamily,
),
),
);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/src/elements/flow_element.dart';
import 'package:flutter_flow_chart/src/objects/element_text_widget.dart';
/// A kind of element
class HexagonWidget extends StatelessWidget {
///
const HexagonWidget({
required this.element,
super.key,
});
///
final FlowElement element;
@override
Widget build(BuildContext context) {
return SizedBox(
width: element.size.width,
height: element.size.height,
child: Stack(
children: [
CustomPaint(
size: element.size,
painter: _HexagonPainter(
element: element,
),
),
ElementTextWidget(element: element),
],
),
);
}
}
class _HexagonPainter extends CustomPainter {
_HexagonPainter({
required this.element,
});
final FlowElement element;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
final path = Path();
paint
..style = PaintingStyle.fill
..color = element.backgroundColor;
path
..moveTo(0, size.height / 2)
..lineTo(size.width / 4, size.height)
..lineTo(size.width * 3 / 4, size.height)
..lineTo(size.width, size.height / 2)
..lineTo(size.width * 3 / 4, 0)
..lineTo(size.width / 4, 0)
..close();
if (element.elevation > 0.01) {
canvas.drawShadow(
path.shift(Offset(element.elevation, element.elevation)),
Colors.black,
element.elevation,
true,
);
}
canvas.drawPath(path, paint);
paint
..strokeWidth = element.borderThickness
..color = element.borderColor
..style = PaintingStyle.stroke;
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/src/elements/flow_element.dart';
import 'package:flutter_flow_chart/src/objects/element_text_widget.dart';
/// A kind of element
class OvalWidget extends StatelessWidget {
///
const OvalWidget({
required this.element,
super.key,
});
///
final FlowElement element;
@override
Widget build(BuildContext context) {
return SizedBox(
width: element.size.width,
height: element.size.height,
child: Stack(
children: [
CustomPaint(
size: element.size,
painter: _OvalPainter(
element: element,
),
),
ElementTextWidget(element: element),
],
),
);
}
}
class _OvalPainter extends CustomPainter {
_OvalPainter({
required this.element,
});
final FlowElement element;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
final path = Path();
paint
..style = PaintingStyle.fill
..color = element.backgroundColor;
path.addOval(Rect.fromLTWH(0, 0, size.width, size.height));
if (element.elevation > 0.01) {
canvas.drawShadow(
path.shift(Offset(element.elevation, element.elevation)),
Colors.black,
element.elevation,
true,
);
}
canvas.drawPath(path, paint);
paint
..strokeWidth = element.borderThickness
..color = element.borderColor
..style = PaintingStyle.stroke;
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}

View File

@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/src/elements/flow_element.dart';
import 'package:flutter_flow_chart/src/objects/element_text_widget.dart';
/// A kind of element
class ParallelogramWidget extends StatelessWidget {
///
const ParallelogramWidget({
required this.element,
super.key,
});
///
final FlowElement element;
@override
Widget build(BuildContext context) {
return SizedBox(
width: element.size.width,
height: element.size.height,
child: Stack(
children: [
CustomPaint(
size: element.size,
painter: _ParallelogramPainter(
element: element,
),
),
ElementTextWidget(element: element),
],
),
);
}
}
class _ParallelogramPainter extends CustomPainter {
_ParallelogramPainter({
required this.element,
});
final FlowElement element;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
final path = Path();
paint
..style = PaintingStyle.fill
..color = element.backgroundColor;
path
..moveTo(size.width / 8, 0)
..lineTo(size.width, 0)
..lineTo(size.width - size.width / 8, size.height)
..lineTo(0, size.height)
..close();
if (element.elevation > 0.01) {
canvas.drawShadow(
path.shift(Offset(element.elevation, element.elevation)),
Colors.black,
element.elevation,
true,
);
}
canvas.drawPath(path, paint);
paint
..strokeWidth = element.borderThickness
..color = element.borderColor
..style = PaintingStyle.stroke;
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/src/elements/flow_element.dart';
import 'package:flutter_flow_chart/src/objects/element_text_widget.dart';
/// A kind of element
class RectangleWidget extends StatelessWidget {
///
const RectangleWidget({
required this.element,
super.key,
});
///
final FlowElement element;
@override
Widget build(BuildContext context) {
return SizedBox(
width: element.size.width,
height: element.size.height,
child: Stack(
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: element.backgroundColor,
boxShadow: [
if (element.elevation > 0.01)
BoxShadow(
color: Colors.grey,
offset: Offset(element.elevation, element.elevation),
blurRadius: element.elevation * 1.3,
),
],
border: Border.all(
color: element.borderColor,
width: element.borderThickness,
),
),
),
ElementTextWidget(element: element),
],
),
);
}
}

View File

@@ -0,0 +1,88 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/src/elements/flow_element.dart';
import 'package:flutter_flow_chart/src/objects/element_text_widget.dart';
/// A kind of element
class StorageWidget extends StatelessWidget {
///
const StorageWidget({
required this.element,
super.key,
});
///
final FlowElement element;
@override
Widget build(BuildContext context) {
return SizedBox(
width: element.size.width,
height: element.size.height,
child: Stack(
children: [
CustomPaint(
size: element.size,
painter: _StoragePainter(
element: element,
),
),
ElementTextWidget(element: element),
],
),
);
}
}
class _StoragePainter extends CustomPainter {
_StoragePainter({
required this.element,
});
final FlowElement element;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
final path = Path();
final path2 = Path();
paint
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.fill
..color = element.backgroundColor;
path2
..moveTo(size.width, size.height / 4.0 / 2.0)
..lineTo(size.width, size.height)
..lineTo(0, size.height)
..lineTo(0, size.height / 4.0 / 2.0)
// oval
..addArc(Rect.fromLTWH(0, 0, size.width, size.height / 4.0), pi, pi)
..addArc(Rect.fromLTWH(0, 0, size.width, size.height / 4.0), 0, pi)
..addArc(Rect.fromLTWH(0, 4, size.width, size.height / 4.0 + 4), 0, pi);
if (element.elevation > 0.01) {
canvas.drawShadow(
path2.shift(Offset(element.elevation, element.elevation)),
Colors.black,
element.elevation,
true,
);
}
canvas.drawPath(path2, paint);
paint
..strokeWidth = element.borderThickness
..color = element.borderColor
..style = PaintingStyle.stroke;
canvas
..drawPath(path, paint)
..drawPath(path2, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}

View File

@@ -0,0 +1,859 @@
import 'dart:convert';
import 'dart:ui' as ui;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/flutter_flow_chart.dart';
import 'package:flutter_flow_chart/src/ui/segment_handler.dart';
enum ArrowDash {
/// Arrow pointing from the source to the destination.
line,
largeDash,
mediumDash,
smallDash,
heavyDotted,
mediumDotted,
lightDotted,
}
double spaceArrowDash(ArrowDash dash) {
if (dash == ArrowDash.line) { return 0;
} else if (dash == ArrowDash.largeDash) { return 12;
} else if (dash == ArrowDash.mediumDash) { return 10;
} else if (dash == ArrowDash.smallDash) { return 5;
} else if (dash == ArrowDash.heavyDotted) { return 2.5;
} else if (dash == ArrowDash.mediumDotted) { return 5;
} else if (dash == ArrowDash.lightDotted) { return 10; }
return 0;
}
double widthArrowDash(ArrowDash dash) {
if (dash == ArrowDash.line) { return 0;
} else if (dash == ArrowDash.largeDash) { return 7;
} else if (dash == ArrowDash.mediumDash) { return 5;
} else if (dash == ArrowDash.smallDash) { return 2.5;
} else if (dash == ArrowDash.heavyDotted) { return 1;
} else if (dash == ArrowDash.mediumDotted) { return 1;
} else if (dash == ArrowDash.lightDotted) { return 1; }
return 0;
}
enum ArrowDirection {
/// Arrow pointing from the source to the destination.
forward,
/// Arrow pointing from the destination to the source.
backward,
bidirectionnal,
}
/// Arrow style enumeration
enum ArrowStyle {
/// A curved arrow which points nicely to each handlers
curve,
/// A segmented line where pivot points can be added and curvature between
/// them can be adjusted with a tension.
segmented,
/// A rectangular shaped line.
rectangular,
}
/// Arrow parameters used by [DrawArrow] widget
class ArrowParams extends ChangeNotifier {
///
ArrowParams({
this.dashWidth = 0,
this.dashSpace = 0,
this.backwardWidth = 10,
this.forwardWidth = 10,
this.thickness = 1.7,
this.headRadius = 6,
double tailLength = 25.0,
this.color = Colors.black,
this.style,
this.tension = 1.0,
this.direction = ArrowDirection.forward,
this.startArrowPosition = Alignment.centerRight,
this.endArrowPosition = Alignment.centerLeft,
}) : _tailLength = tailLength;
///
Map<String, dynamic> toMap() {
return <String, dynamic>{
'thickness': thickness,
'head_radius': headRadius,
'tail_length': _tailLength,
'color': color.value,
'arrow_style': style?.index,
'tension': tension,
'direction': direction?.index,
'dash_width': dashWidth,
'dash_space': dashSpace,
'backward_arrow_width': backwardWidth,
'forward_arrow_width': forwardWidth,
'start_arrow_position_x': startArrowPosition.x,
'start_arrow_position_y': startArrowPosition.y,
'end_arrow_position_x': endArrowPosition.x,
'end_arrow_position_y': endArrowPosition.y,
};
}
///
factory ArrowParams.fromMap(Map<String, dynamic> map) {
return ArrowParams(
dashSpace: map['dash_space'] as double,
dashWidth: map['dash_width'] as double,
backwardWidth: map['backward_arrow_width'] as double,
forwardWidth: map['forward_arrow_width'] as double,
thickness: map['thickness'] as double,
direction: ArrowDirection.values[map['direction'] as int? ?? 0],
headRadius: map['head_radius'] as double? ?? 6.0,
tailLength: map['tail_length'] as double? ?? 25.0,
color: Color(map['color'] as int),
style: ArrowStyle.values[map['arrow_style'] as int? ?? 0],
tension: map['tension'] as double? ?? 1,
startArrowPosition: Alignment(
map['start_arrow_position_x'] as double,
map['start_arrow_position_y'] as double,
),
endArrowPosition: Alignment(
map['end_arrow_position_x'] as double,
map['end_arrow_position_y'] as double,
),
);
}
///
factory ArrowParams.fromJson(String source) =>
ArrowParams.fromMap(json.decode(source) as Map<String, dynamic>);
/// Arrow thickness.
double thickness;
double forwardWidth;
double backwardWidth;
/// The radius of arrow tip.
double headRadius;
/// Arrow color.
Color color;
/// The start position alignment.
final Alignment startArrowPosition;
/// The end position alignment.
final Alignment endArrowPosition;
/// The tail length of the arrow.
double _tailLength;
/// The style of the arrow.
ArrowStyle? style;
ArrowDirection? direction;
double dashWidth = 0;
double dashSpace = 0;
/// The curve tension for pivot points when using [ArrowStyle.segmented].
/// 0 means no curve on segments.
double tension;
///
ArrowParams copyWith({
double? thickness,
Color? color,
ArrowStyle? style,
ArrowDirection? direction,
double? tension,
double? dashWidth,
double? dashSpace,
Alignment? startArrowPosition,
Alignment? endArrowPosition,
double? backwardWidth,
double? forwardWidth,
}) {
return ArrowParams(
backwardWidth: backwardWidth ?? this.backwardWidth,
forwardWidth: forwardWidth ?? this.forwardWidth,
thickness: thickness ?? this.thickness,
color: color ?? this.color,
style: style ?? this.style,
dashSpace: dashSpace ?? this.dashSpace,
dashWidth: dashWidth ?? this.dashWidth,
direction: direction ?? this.direction,
tension: tension ?? this.tension,
startArrowPosition: startArrowPosition ?? this.startArrowPosition,
endArrowPosition: endArrowPosition ?? this.endArrowPosition,
);
}
///
String toJson() => json.encode(toMap());
///
void setScale(double currentZoom, double factor) {
thickness = thickness / currentZoom * factor;
headRadius = headRadius / currentZoom * factor;
_tailLength = _tailLength / currentZoom * factor;
notifyListeners();
}
///
double get tailLength => _tailLength;
}
/// Notifier to update arrows position, starting/ending points and params
class DrawingArrow extends ChangeNotifier {
DrawingArrow._();
/// Singleton instance of this.
static final instance = DrawingArrow._();
/// Arrow parameters.
ArrowParams params = ArrowParams();
String fromID = "";
String toID = "";
/// Sets the parameters.
void setParams(ArrowParams params) {
this.params = params;
notifyListeners();
}
/// Starting arrow offset.
Offset from = Offset.zero;
///
void setFrom(Offset from) {
this.from = from;
notifyListeners();
}
/// Ending arrow offset.
Offset to = Offset.zero;
///
void setTo(Offset to) {
this.to = to;
notifyListeners();
}
///
bool isZero() {
return from == Offset.zero && to == Offset.zero;
}
///
void reset() {
params = ArrowParams();
from = Offset.zero;
to = Offset.zero;
notifyListeners();
}
}
/// Draw arrow from [srcElement] to [destElement]
/// using [arrowParams] parameters
class DrawArrow extends StatefulWidget {
ChartWidgetState flow;
///
DrawArrow({
required this.flow,
required this.index,
required this.srcElement,
required this.destElement,
required List<Pivot> pivots,
super.key,
ArrowParams? arrowParams,
}) : arrowParams = arrowParams ?? ArrowParams(),
pivots = PivotsNotifier(pivots);
final int index;
///
final ArrowParams arrowParams;
///
final FlowElement srcElement;
///
final FlowElement destElement;
///
final PivotsNotifier pivots;
@override
State<DrawArrow> createState() => DrawArrowState();
}
class DrawArrowState extends State<DrawArrow> {
@override
void initState() {
super.initState();
widget.srcElement.addListener(_elementChanged);
widget.destElement.addListener(_elementChanged);
widget.pivots.addListener(_elementChanged);
}
@override
void dispose() {
widget.srcElement.removeListener(_elementChanged);
widget.destElement.removeListener(_elementChanged);
widget.pivots.removeListener(_elementChanged);
super.dispose();
}
void _elementChanged() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
var from = Offset.zero;
var to = Offset.zero;
from = Offset(
widget.srcElement.position.dx +
widget.srcElement.handlerSize / 2.0 +
(widget.srcElement.size.width *
((widget.arrowParams.startArrowPosition.x + 1) / 2)),
widget.srcElement.position.dy +
widget.srcElement.handlerSize / 2.0 +
(widget.srcElement.size.height *
((widget.arrowParams.startArrowPosition.y + 1) / 2)),
);
to = Offset(
widget.destElement.position.dx +
widget.destElement.handlerSize / 2.0 +
(widget.destElement.size.width *
((widget.arrowParams.endArrowPosition.x + 1) / 2)),
widget.destElement.position.dy +
widget.destElement.handlerSize / 2.0 +
(widget.destElement.size.height *
((widget.arrowParams.endArrowPosition.y + 1) / 2)),
);
GlobalKey<GraphParamsWidgetState> key = GlobalKey<GraphParamsWidgetState>();
print("THERE");
return Stack( children : [
GraphParamsWidget(key: key, element: widget.srcElement, index: widget.index, comp: widget.flow),
RepaintBoundary(
child: Builder(
builder: (context) {
print(from);
print(to);
print(widget.pivots);
var painter = ArrowPainter(
connIndex: widget.index,
elementIndex: widget.flow.widget.dashboard.elements.indexOf(widget.srcElement),
fromID: "${widget.srcElement.id}_${widget.index}",
toID: "${widget.destElement.id}_${widget.index}",
params: widget.arrowParams,
from: from, to: to,
pivots: widget.pivots.value,
);
if ( widget.flow.widget.dashboard.arrows.where(
(element) => element.fromID == "${widget.srcElement.id}${widget.index}").isEmpty) {
widget.flow.widget.dashboard.arrows.add(painter);
} else {
var i = widget.flow.widget.dashboard.arrows.indexWhere(
(element) => element.fromID == "${widget.srcElement.id}${widget.index}");
painter.isSelected = widget.flow.widget.dashboard.arrows[i].isSelected;
widget.flow.widget.dashboard.arrows[i] = painter;
}
return CustomPaint(
painter: painter,
child: Container(),
);
},
),
)
]);
}
}
class GraphParamsWidget extends StatefulWidget {
ChartWidgetState comp;
bool isShowed = false;
Offset? position;
FlowElement element;
int index;
GraphParamsWidget({ Key? key, required this.element, required this.comp,
required this.index }): super(key: key);
@override GraphParamsWidgetState createState() => GraphParamsWidgetState();
}
class GraphParamsWidgetState extends State<GraphParamsWidget> {
@override Widget build(BuildContext context) {
return !widget.isShowed ? Container() : Positioned(
top: (widget.position?.dy ?? 0) - 5, left: (widget.position?.dx ?? 0) - 5,
child: MouseRegion( cursor: SystemMouseCursors.click,
onHover: (event) => setState(() { widget.isShowed = true; }),
child: Container(
child: Row(children: [
IconButton(onPressed: () {
widget.comp.setState(() {
widget.comp.widget.dashboard.arrows.removeWhere((el) => el.fromID == "${widget.element.id}${widget.index}");
widget.element.next.removeAt(widget.index);
});
}, icon: Icon(Icons.delete))
],))));
}
}
class ArrowInfoWidget extends StatefulWidget {
ArrowInfoWidget ({ Key? key, }): super(key: key);
@override ArrowInfoWidgetState createState() => ArrowInfoWidgetState();
}
class ArrowInfoWidgetState extends State<ArrowInfoWidget> {
@override Widget build(BuildContext context) {
return SingleChildScrollView( child: Column(children: [
Container(height: 50,
decoration: BoxDecoration(color: Colors.grey.shade300, border: Border(bottom: BorderSide(color: Colors.grey, width: 1))),
child: Center( child: Text("<Arrow> Style", style: TextStyle(fontSize: 20)))),
Container(height: 50,
decoration: BoxDecoration(color: Colors.grey.shade300, border: Border(bottom: BorderSide(color: Colors.grey, width: 1))),
child: Row(children: [],)
),
],) );
}
}
/// Paint the arrow connection taking in count the
/// [ArrowParams.startArrowPosition] and
/// [ArrowParams.endArrowPosition] alignment.
class ArrowPainter extends CustomPainter {
final String fromID;
final String toID;
bool isSelected = false;
///
ArrowPainter({
this.elementIndex,
this.connIndex,
required this.toID,
required this.fromID,
this.isSelected = false,
required this.params,
required this.from,
required this.to,
List<Pivot>? pivots,
}) : pivots = pivots ?? [];
///
final ArrowParams params;
///
Offset from;
int? elementIndex;
int? connIndex;
///
Offset to;
///
Path path = Path();
Path dashed = Path();
Path subPath = Path();
///
final List<List<Offset>> lines = [];
///
final List<Pivot> pivots;
late DashedPathProperties _dashedPathProperties;
final arrowSize = 15;
final arrowAngle= 25 * math.pi / 180;
Map<String, dynamic> serialize() {
Map<String, dynamic> graphElement = {};
graphElement['from'] = { "id" : fromID.split("_")[0], "x" : from.dx, "y" : from.dy };
graphElement['to'] = { "id" : toID.split("_")[0], "x" : to.dx, "y" : to.dy };
graphElement['params'] = params.toMap();
return graphElement;
}
factory ArrowPainter.fromMap(Map<String, dynamic> map) {
final e = ArrowPainter(
connIndex: map['connIndex'] as int,
elementIndex: map['elementIndex'] as int,
toID: map['toID'] as String,
fromID: map['fromID'] as String,
isSelected: map['isSelected'] as bool,
params: ArrowParams.fromMap(map['params']),
from: Offset(
map['fromDx'] as double,
map['fromDy'] as double,
),
to: Offset(
map['toDx'] as double,
map['toDy'] as double,
),
pivots: (map['pivots'] as List).map<Pivot>((e) => Pivot.fromMap(e as Map<String, dynamic>)).toList(),
);
return e;
}
Map<String, dynamic> toMap() {
return {
'fromID': fromID,
'elementIndex': elementIndex,
'connIndex': connIndex,
'isSelected': isSelected.toString(),
'params': params.toJson(),
'from': from.toString(),
'to': to.toString(),
'pivots': json.encode(pivots.map((e) => e.toMap()).toList()),
};
}
@override
void paint(Canvas canvas, Size size) {
if (params.dashSpace > 0) {
_dashedPathProperties = DashedPathProperties(
path: Path(),
dashLength: params.dashWidth,
dashGapLength: params.dashSpace,
);
}
final paint = Paint()..strokeWidth = params.thickness;
from = Offset(from.dx - params.headRadius - 2, from.dy - params.headRadius - 2 );
to = Offset(to.dx - params.headRadius - 3, to.dy - params.headRadius - 2 );
if (params.style == ArrowStyle.curve) { drawCurve(canvas, paint);
} else if (params.style == ArrowStyle.segmented) { drawLine();
} else if (params.style == ArrowStyle.rectangular) { drawRectangularLine(canvas, paint); }
paint
..color = isSelected ? Colors.red : params.color
..style = PaintingStyle.stroke;
canvas.drawPath(dashed, paint);
canvas.drawPath(subPath, paint);
}
Path drawArrow(Offset a, Offset b, Alignment? start, bool isCurve, bool backward) {
double arrowSize = 0;
if (backward) { arrowSize = params.backwardWidth;
} else { arrowSize = params.forwardWidth; }
const arrowAngle = math.pi / 6;
final dX = b.dx - a.dx;
final dY = b.dy - a.dy;
double angle = math.atan2(dY, dX);
if (start == Alignment.bottomCenter) { angle = -33 - (isCurve ? 3 : 0);
} else if (start == Alignment.topCenter) { angle = 33 + (isCurve ? 3 : 0);
} else if (start == Alignment.centerRight) { angle = 66;
} else if (start == Alignment.centerLeft) { angle = 0; }
subPath.moveTo(b.dx - arrowSize * math.cos(angle - arrowAngle),
b.dy - arrowSize * math.sin(angle - arrowAngle));
subPath.lineTo(b.dx, b.dy);
subPath.lineTo(b.dx - arrowSize * math.cos(angle + arrowAngle),
b.dy - arrowSize * math.sin(angle + arrowAngle));
return subPath;
}
/// Draw a segmented line with a tension between points.
void drawLine() {
final points = [from];
for (final pivot in pivots) {
points.add(pivot.pivot);
}
points.add(to);
path.moveTo(points.first.dx, points.first.dy);
for (var i = 0; i < points.length - 1; i++) {
final p0 = (i > 0) ? points[i - 1] : points[0];
final p1 = points[i];
final p2 = points[i + 1];
final p3 = (i != points.length - 2) ? points[i + 2] : p2;
final cp1x = p1.dx + (p2.dx - p0.dx) / 6 * params.tension;
final cp1y = p1.dy + (p2.dy - p0.dy) / 6 * params.tension;
final cp2x = p2.dx - (p3.dx - p1.dx) / 6 * params.tension;
final cp2y = p2.dy - (p3.dy - p1.dy) / 6 * params.tension;
path.cubicTo(cp1x, cp1y, cp2x, cp2y, p2.dx, p2.dy);
}
if (params.dashSpace > 0) {
dashed = _getDashedPath(path, params.dashWidth, params.dashSpace);
} else { dashed = path; }
if (ArrowDirection.forward == params.direction || ArrowDirection.bidirectionnal == params.direction) {
subPath = drawArrow(from, to, null, false, false);
}
if (ArrowDirection.backward == params.direction || ArrowDirection.bidirectionnal == params.direction) {
subPath = drawArrow(to, from, null, false, true); }
}
/// Draw a rectangular line
void drawRectangularLine(Canvas canvas, Paint paint) {
// calculating offsetted pivot
var pivot1 = Offset(from.dx, from.dy);
if (params.startArrowPosition.y == 1) {
pivot1 = Offset(from.dx, from.dy + params.tailLength);
} else if (params.startArrowPosition.y == -1) {
pivot1 = Offset(from.dx, from.dy - params.tailLength);
}
final pivot2 = Offset(to.dx, pivot1.dy);
path
..moveTo(from.dx, from.dy)
..lineTo(pivot1.dx, pivot1.dy)
..lineTo(pivot2.dx, pivot2.dy)
..lineTo(to.dx, to.dy);
lines.addAll([
[from, pivot2],
[pivot2, to],
]);
if (params.dashSpace != 0) {
dashed = _getDashedPath(path, params.dashWidth, params.dashSpace);
} else { dashed = path; }
Alignment start = Alignment.centerLeft;
if (pivot2.dy != to.dy) {
if (to.dy < pivot2.dy) { start = Alignment.bottomCenter;
} else { start = Alignment.topCenter; }
}
if (pivot2.dx != to.dx) {
if (to.dx < pivot2.dx) { start = Alignment.centerRight;
} else { start = Alignment.centerLeft; }
}
if (ArrowDirection.forward == params.direction || ArrowDirection.bidirectionnal == params.direction) {
subPath = drawArrow(from, to, start, false, false);
}
if (ArrowDirection.backward == params.direction || ArrowDirection.bidirectionnal == params.direction) {
subPath = drawArrow(to, from, params.startArrowPosition, false, true); }
}
/// Draws a curve starting/ending the handler linearly from the center
/// of the element.
void drawCurve(Canvas canvas, Paint paint) {
var distance = 0.0;
var dx = 0.0;
var dy = 0.0;
final p0 = Offset(from.dx, from.dy);
final p4 = Offset(to.dx, to.dy);
distance = (p4 - p0).distance / 3;
// checks for the arrow direction
if (params.startArrowPosition.x > 0) {
dx = distance;
} else if (params.startArrowPosition.x < 0) {
dx = -distance;
}
if (params.startArrowPosition.y > 0) {
dy = distance;
} else if (params.startArrowPosition.y < 0) {
dy = -distance;
}
final p1 = Offset(from.dx + dx, from.dy + dy);
dx = 0;
dy = 0;
// checks for the arrow direction
if (params.endArrowPosition.x > 0) {
dx = distance;
} else if (params.endArrowPosition.x < 0) {
dx = -distance;
}
if (params.endArrowPosition.y > 0) {
dy = distance;
} else if (params.endArrowPosition.y < 0) {
dy = -distance;
}
final p3 = params.endArrowPosition == Alignment.center
? Offset(to.dx, to.dy)
: Offset(to.dx + dx, to.dy + dy);
final p2 = Offset(
p1.dx + (p3.dx - p1.dx) / 2,
p1.dy + (p3.dy - p1.dy) / 2,
);
path
..moveTo(p0.dx, p0.dy)
..conicTo(p1.dx, p1.dy, p2.dx, p2.dy, 1)
..conicTo(p3.dx, p3.dy, p4.dx, p4.dy, 1);
if (params.dashSpace > 0) {
dashed = _getDashedPath(path, params.dashWidth, params.dashSpace);
} else { dashed = path; }
Alignment start = Alignment.center;
if (p3.dy != p4.dy) {
if (p4.dy < p3.dy) { start = Alignment.bottomCenter;
} else { start = Alignment.topCenter; }
}
if (p3.dx != p4.dx) {
if (p4.dx < p3.dx) { start = Alignment.centerRight;
} else { start = Alignment.centerLeft; }
}
if (ArrowDirection.forward == params.direction || ArrowDirection.bidirectionnal == params.direction) {
subPath = drawArrow(from, to, start, start == Alignment.center, false);
}
if (ArrowDirection.backward == params.direction || ArrowDirection.bidirectionnal == params.direction) {
subPath = drawArrow(to, from, params.startArrowPosition, params.startArrowPosition == Alignment.center, true);
}
}
@override
bool shouldRepaint(ArrowPainter oldDelegate) {
return true;
}
Path _getDashedPath(
Path originalPath, double dashLength, double dashGapLength) {
final metricsIterator = originalPath.computeMetrics().iterator;
while (metricsIterator.moveNext()) {
final metric = metricsIterator.current;
_dashedPathProperties.extractedPathLength = 0.0;
while (_dashedPathProperties.extractedPathLength < metric.length) {
if (_dashedPathProperties.addDashNext) {
_dashedPathProperties.addDash(metric, dashLength);
} else {
_dashedPathProperties.addDashGap(metric, dashGapLength);
}
}
}
return _dashedPathProperties.path;
}
bool isLine(Offset position) {
for (double i=-20; i < 20; i++) {
for (double y=-20; y < 20; y++) {
var pos = position + Offset(i, y);
if (path.contains(pos)) {
return true;
}
}
}
return false;
}
@override
bool? hitTest(Offset position) {
/* graphkey?.currentState?.widget.isShowed = isLine(position);
if (isLine(position)) { graphkey?.currentState?.widget.position = graphkey?.currentState?.widget.position ?? position; }
Future.delayed(Duration(seconds: 1), () {
if (graphkey != null && graphkey!.currentState != null && !graphkey!.currentState!.widget.isShowed) {
graphkey?.currentState?.widget.position = null;
}
});
graphkey?.currentState?.setState(() {});*/
return false;
}
}
/// Notifier for pivot points.
class PivotsNotifier extends ValueNotifier<List<Pivot>> {
///
PivotsNotifier(super.value) {
for (final pivot in value) {
pivot.addListener(notifyListeners);
}
}
/// Add a pivot point.
void add(Pivot pivot) {
value.add(pivot);
pivot.addListener(notifyListeners);
notifyListeners();
}
/// Remove a pivot point.
void remove(Pivot pivot) {
value.remove(pivot);
pivot.removeListener(notifyListeners);
notifyListeners();
}
/// Insert a pivot point.
void insert(int index, Pivot pivot) {
value.insert(index, pivot);
pivot.addListener(notifyListeners);
notifyListeners();
}
/// Remove a pivot point by its index.
void removeAt(int index) {
value.removeAt(index).removeListener(notifyListeners);
notifyListeners();
}
}
class DashedPathProperties {
double extractedPathLength;
Path path;
final double _dashLength;
double _remainingDashLength;
double _remainingDashGapLength;
bool _previousWasDash = false;
DashedPathProperties({
required this.path,
required double dashLength,
required double dashGapLength,
}) :
_dashLength = dashLength,
_remainingDashLength = dashLength,
_remainingDashGapLength = dashGapLength,
_previousWasDash = false,
extractedPathLength = 0.0;
bool get addDashNext {
if (_remainingDashLength != _dashLength || !_previousWasDash) {
return true;
}
return false;
}
void addDash(ui.PathMetric metric, double dashLength) {
// Calculate lengths (actual + available)
final end = _calculateLength(metric, _remainingDashLength);
final availableEnd = _calculateLength(metric, dashLength);
// Add path
final pathSegment = metric.extractPath(extractedPathLength, end);
path.addPath(pathSegment, Offset.zero);
// Update
final delta = _remainingDashLength - (end - extractedPathLength);
_remainingDashLength = _updateRemainingLength(
delta: delta,
end: end,
availableEnd: availableEnd,
initialLength: dashLength,
);
extractedPathLength = end;
_previousWasDash = true;
}
void addDashGap(ui.PathMetric metric, double dashGapLength) {
// Calculate lengths (actual + available)
final end = _calculateLength(metric, _remainingDashGapLength);
final availableEnd = _calculateLength(metric, dashGapLength);
// Move path's end point
ui.Tangent tangent = metric.getTangentForOffset(end)!;
path.moveTo(tangent.position.dx, tangent.position.dy);
// Update
final delta = end - extractedPathLength;
_remainingDashGapLength = _updateRemainingLength(
delta: delta,
end: end,
availableEnd: availableEnd,
initialLength: dashGapLength,
);
extractedPathLength = end;
_previousWasDash = false;
}
double _calculateLength(ui.PathMetric metric, double addedLength) {
return math.min(extractedPathLength + addedLength, metric.length);
}
double _updateRemainingLength({
required double delta,
required double end,
required double availableEnd,
required double initialLength,
}) {
return (delta > 0 && availableEnd == end) ? delta : initialLength;
}
}

View File

@@ -0,0 +1,296 @@
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/src/dashboard.dart';
import 'package:flutter_flow_chart/src/elements/flow_element.dart';
import 'package:flutter_flow_chart/src/ui/draw_arrow.dart';
import 'package:flutter_flow_chart/src/ui/handler_widget.dart';
/// Draw handlers over the element
class ElementHandlers extends StatelessWidget {
///
const ElementHandlers({
required this.dashboard,
required this.element,
required this.handlerSize,
required this.child,
required this.onHandlerPressed,
required this.onHandlerSecondaryTapped,
required this.onHandlerLongPressed,
required this.onHandlerSecondaryLongTapped,
super.key,
});
///
final Dashboard dashboard;
///
final FlowElement element;
///
final Widget child;
///
final double handlerSize;
///
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerPressed;
///
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerLongPressed;
///
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerSecondaryTapped;
///
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerSecondaryLongTapped;
@override
Widget build(BuildContext context) {
return SizedBox(
width: element.size.width + handlerSize,
height: element.size.height + handlerSize,
child: Stack(
alignment: Alignment.center,
children: [
child,
for (int i = 0; i < element.handlers.length; i++)
_ElementHandler(
element: element,
handler: element.handlers[i],
dashboard: dashboard,
handlerSize: handlerSize,
onHandlerPressed: onHandlerPressed,
onHandlerSecondaryTapped: onHandlerSecondaryTapped,
onHandlerLongPressed: onHandlerLongPressed,
onHandlerSecondaryLongTapped: onHandlerSecondaryLongTapped,
),
],
),
);
}
}
class _ElementHandler extends StatelessWidget {
const _ElementHandler({
required this.element,
required this.handler,
required this.dashboard,
required this.handlerSize,
required this.onHandlerPressed,
required this.onHandlerSecondaryTapped,
required this.onHandlerLongPressed,
required this.onHandlerSecondaryLongTapped,
});
final FlowElement element;
final Handler handler;
final Dashboard dashboard;
final double handlerSize;
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerPressed;
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerSecondaryTapped;
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerLongPressed;
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerSecondaryLongTapped;
@override
Widget build(BuildContext context) {
var isDragging = false;
Alignment alignment;
switch (handler) {
case Handler.topCenter:
alignment = Alignment.topCenter;
case Handler.bottomCenter:
alignment = Alignment.bottomCenter;
case Handler.leftCenter:
alignment = Alignment.centerLeft;
case Handler.rightCenter:
alignment = Alignment.centerRight;
}
var tapDown = Offset.zero;
var secondaryTapDown = Offset.zero;
return Align(
alignment: alignment,
child: DragTarget<Map<dynamic, dynamic>>(
onWillAcceptWithDetails: (details) {
DrawingArrow.instance.fromID = element.id;
DrawingArrow.instance.setParams(
DrawingArrow.instance.params.copyWith(
endArrowPosition: alignment,
style: dashboard.defaultArrowStyle,
direction: dashboard.defaultArrowDirection,
thickness: dashboard.defaultStroke,
dashSpace: dashboard.defaultDashSpace,
dashWidth: dashboard.defaultDashWidth,
color: dashboard.defaultColor,
backwardWidth: dashboard.defaultBackWidth,
forwardWidth: dashboard.defaultForwardWidth,
),
);
return element != details.data['srcElement'] as FlowElement;
},
onAcceptWithDetails: (details) {
dashboard.addNextById(
details.data['srcElement'] as FlowElement,
element.id,
DrawingArrow.instance.params.copyWith(
endArrowPosition: alignment,
),
);
},
onLeave: (data) {
DrawingArrow.instance.setParams(
DrawingArrow.instance.params.copyWith(
endArrowPosition: Alignment.center,
style: dashboard.defaultArrowStyle,
direction: dashboard.defaultArrowDirection,
thickness: dashboard.defaultStroke,
dashSpace: dashboard.defaultDashSpace,
dashWidth: dashboard.defaultDashWidth,
color: dashboard.defaultColor,
backwardWidth: dashboard.defaultBackWidth,
forwardWidth: dashboard.defaultForwardWidth,
),
);
},
builder: (context, candidateData, rejectedData) {
return Draggable(
feedback: const SizedBox.shrink(),
feedbackOffset: dashboard.handlerFeedbackOffset,
childWhenDragging: HandlerWidget(
width: handlerSize,
height: handlerSize,
backgroundColor: Colors.blue,
),
data: {
'srcElement': element,
'alignment': alignment,
},
child: GestureDetector(
onTapDown: (details) =>
tapDown = details.globalPosition - dashboard.position,
onSecondaryTapDown: (details) => secondaryTapDown =
details.globalPosition - dashboard.position,
onTap: () {
onHandlerPressed?.call(
context,
tapDown,
handler,
element,
);
},
onSecondaryTap: () {
onHandlerSecondaryTapped?.call(
context,
secondaryTapDown,
handler,
element,
);
},
onLongPress: () {
onHandlerLongPressed?.call(
context,
tapDown,
handler,
element,
);
},
onSecondaryLongPress: () {
onHandlerSecondaryLongTapped?.call(
context,
secondaryTapDown,
handler,
element,
);
},
child: HandlerWidget(
width: handlerSize,
height: handlerSize,
),
),
onDragUpdate: (details) {
if (!isDragging && DrawingArrow.instance.from == Offset.zero) {
DrawingArrow.instance.fromID = element.id;
DrawingArrow.instance.params = ArrowParams(
startArrowPosition: alignment,
endArrowPosition: Alignment.center,
style: dashboard.defaultArrowStyle,
direction: dashboard.defaultArrowDirection,
thickness: dashboard.defaultStroke,
dashSpace: dashboard.defaultDashSpace,
dashWidth: dashboard.defaultDashWidth,
color: dashboard.defaultColor,
backwardWidth: dashboard.defaultBackWidth,
forwardWidth: dashboard.defaultForwardWidth,
);
DrawingArrow.instance.from =
details.globalPosition - Offset(-10, 45);
isDragging = true;
}
DrawingArrow.instance.to = details.globalPosition - Offset(-10, 50);
DrawingArrow.instance.setParams(
DrawingArrow.instance.params.copyWith(
endArrowPosition: Alignment.center,
style: dashboard.defaultArrowStyle,
direction: dashboard.defaultArrowDirection,
thickness: dashboard.defaultStroke,
dashSpace: dashboard.defaultDashSpace,
dashWidth: dashboard.defaultDashWidth,
color: dashboard.defaultColor,
backwardWidth: dashboard.defaultBackWidth,
forwardWidth: dashboard.defaultForwardWidth,
),
);
},
onDragEnd: (details) {
DrawingArrow.instance.reset();
isDragging = false;
},
);
},
),
);
}
}

View File

@@ -0,0 +1,262 @@
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/flutter_flow_chart.dart';
import 'package:flutter_flow_chart/src/objects/diamond_widget.dart';
import 'package:flutter_flow_chart/src/objects/hexagon_widget.dart';
import 'package:flutter_flow_chart/src/objects/oval_widget.dart';
import 'package:flutter_flow_chart/src/objects/parallelogram_widget.dart';
import 'package:flutter_flow_chart/src/objects/rectangle_widget.dart';
import 'package:flutter_flow_chart/src/objects/storage_widget.dart';
import 'package:flutter_flow_chart/src/ui/draw_arrow.dart';
import 'package:flutter_flow_chart/src/ui/element_handlers.dart';
import 'package:flutter_flow_chart/src/ui/resize_widget.dart';
import 'package:flutter_flow_chart/src/objects/any_widget.dart';
/// Widget that use [element] properties to display it on the dashboard scene
class ElementWidget extends StatefulWidget {
///
ElementWidget({
required this.dashboard,
required this.element,
super.key,
this.onElementPressed,
this.onElementSecondaryTapped,
this.onElementLongPressed,
this.onElementSecondaryLongTapped,
this.onHandlerPressed,
this.onHandlerSecondaryTapped,
this.onHandlerLongPressed,
this.onHandlerSecondaryLongTapped,
});
///
final Dashboard dashboard;
///
final FlowElement element;
///
bool isHovered = false;
///
final void Function(BuildContext context, Offset position)? onElementPressed;
///
final void Function(BuildContext context, Offset position)?
onElementSecondaryTapped;
///
final void Function(BuildContext context, Offset position)?
onElementLongPressed;
///
final void Function(BuildContext context, Offset position)?
onElementSecondaryLongTapped;
///
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerPressed;
///
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerSecondaryTapped;
///
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerLongPressed;
///
final void Function(
BuildContext context,
Offset position,
Handler handler,
FlowElement element,
)? onHandlerSecondaryLongTapped;
@override
State<ElementWidget> createState() => ElementWidgetState();
}
class ElementWidgetState extends State<ElementWidget> {
// local widget touch position when start dragging
Offset delta = Offset.zero;
@override
void initState() {
super.initState();
widget.element.addListener(_elementChanged);
}
@override
void dispose() {
widget.element.removeListener(_elementChanged);
super.dispose();
}
void _elementChanged() {
setState(() {});
}
@override
Widget build(BuildContext context) {
Widget element;
switch (widget.element.kind) {
case ElementKind.diamond:
element = DiamondWidget(element: widget.element);
case ElementKind.storage:
element = StorageWidget(element: widget.element);
case ElementKind.oval:
element = OvalWidget(element: widget.element);
case ElementKind.parallelogram:
element = ParallelogramWidget(element: widget.element);
case ElementKind.hexagon:
element = HexagonWidget(element: widget.element);
case ElementKind.rectangle:
element = RectangleWidget(element: widget.element);
case ElementKind.widget:
if (widget.element.widget == null) { element = RectangleWidget(element: widget.element);
} else { element = AnyWidget(element: widget.element); }
}
var tapLocation = Offset.zero;
var secondaryTapDownPos = Offset.zero;
Widget w = GestureDetector(
onTapDown: (details) => tapLocation = details.globalPosition,
onSecondaryTapDown: (details) =>
secondaryTapDownPos = details.globalPosition,
onTap: () {
setState(() {
widget.element.isSelected = !widget.element.isSelected;
for (var sel in widget.dashboard.arrows) { sel.isSelected = false; }
widget.dashboard.selectedMenuKey.currentState?. setState(() { });
Future.delayed(Duration(seconds: 1), () { DrawingArrow.instance.notifyListeners(); });
});
widget.onElementPressed?.call(context, tapLocation);
},
onSecondaryTap: () {
widget.onElementSecondaryTapped?.call(context, secondaryTapDownPos);
},
onLongPress: () {
widget.onElementLongPressed?.call(context, tapLocation);
},
onSecondaryLongPress: () {
widget.onElementSecondaryLongTapped
?.call(context, secondaryTapDownPos);
},
child: Listener(
onPointerDown: (event) {
delta = event.localPosition;
},
child: Draggable<FlowElement>(
data: widget.element,
dragAnchorStrategy: childDragAnchorStrategy,
childWhenDragging: const SizedBox.shrink(),
feedback: Material(
color: Colors.transparent,
child: Padding( padding: EdgeInsets.all(6),
child: Container(
decoration: BoxDecoration(border: Border.all(
color: Colors.red, width: 2)),
width: widget.element.size.width - 12,
height: widget.element.size.height - 12, child: element)
),
),
child: ElementHandlers(
dashboard: widget.dashboard,
element: widget.element,
handlerSize: widget.element.handlerSize,
onHandlerPressed: widget.onHandlerPressed,
onHandlerSecondaryTapped: widget.onHandlerSecondaryTapped,
onHandlerLongPressed: widget.onHandlerLongPressed,
onHandlerSecondaryLongTapped: widget.onHandlerSecondaryLongTapped,
child: Container(
margin: EdgeInsets.all(6),
decoration: BoxDecoration(
border: Border.all(color: widget.element.isSelected ? Colors.red : Colors.grey.shade300,
width: widget.element.isSelected ? 2 : 1),
),
child: InkWell( mouseCursor: SystemMouseCursors.grab, child: element )
)
),
onDragStarted: () {
widget.element.isSelected = true;
widget.dashboard.selectedMenuKey.currentState?. setState(() { });
},
onDragUpdate: (details) {
var diff = Offset(widget.element.position.dx, widget.element.position.dy);
widget.element.isSelected = true;
widget.element.changePosition(
details.globalPosition - delta - Offset(0, 50),
);
diff = widget.element.position - diff;
if (widget.element.isSelected) {
for (var sel in widget.dashboard.elementSelected) {
if (widget.element == sel) { continue; }
sel.changePosition(sel.position + diff);
}
}
},
onDragEnd: (details) {
var diff = Offset(widget.element.position.dx, widget.element.position.dy);
widget.element.changePosition(details.offset - Offset(0, 50));
diff = widget.element.position - diff;
if (widget.element.isSelected) {
for (var sel in widget.dashboard.elementSelected) {
if (widget.element == sel) { continue; }
sel.changePosition(sel.position + diff);
}
}
},
),
),
);
w = ResizeWidget(
comp: this,
element: widget.element,
dashboard: widget.dashboard,
handlerColor: widget.isHovered ? Color.fromRGBO(38, 166, 154, 1) : Colors.transparent,
child: w );
return Transform.translate(
offset: widget.element.position,
transformHitTests: true,
child: MouseRegion(
onEnter: (event) { setState(() { widget.isHovered = true; }); },
onExit: (event) { setState(() { widget.isHovered = false; }); },
child: Wrap( direction: Axis.vertical, children: [
w,
Container(
constraints: BoxConstraints( minWidth: widget.element.size.width ),
child: Row( mainAxisAlignment: MainAxisAlignment.center,
children: (!widget.isHovered ? [] : [
IconButton(tooltip: "remove element", onPressed: () {
widget.dashboard.removeElement(widget.element);
}, icon: Icon(Icons.delete_outline)),
IconButton(tooltip: "copy element", onPressed: () {
FlowElement newElement = FlowElement(
kind: widget.element.kind,
position: widget.element.position + Offset(widget.element.size.width, widget.element.size.height),
size: widget.element.size,
widget: widget.element.widget,
);
widget.dashboard.addElement(newElement);
}, icon: Icon(Icons.copy, size: 20)),
]))
)
])
)
);
}
}

View File

@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
/// Defines grid parameters.
class GridBackgroundParams extends ChangeNotifier {
/// [gridSquare] is the raw size of the grid square when scale is 1
GridBackgroundParams({
double gridSquare = 20.0,
this.gridThickness = 0.7,
this.secondarySquareStep = 5,
this.backgroundColor = Colors.white,
this.gridColor = Colors.black12,
void Function(double scale)? onScaleUpdate,
}) : rawGridSquareSize = gridSquare {
if (onScaleUpdate != null) {
_onScaleUpdateListeners.add(onScaleUpdate);
}
}
///
factory GridBackgroundParams.fromMap(Map<String, dynamic> map) {
final params = GridBackgroundParams(
gridSquare: map['gridSquare'] as double? ?? 20.0,
gridThickness: map['gridThickness'] as double? ?? 0.7,
secondarySquareStep: map['secondarySquareStep'] as int? ?? 5,
backgroundColor: Color(map['backgroundColor'] as int? ?? 0xFFFFFFFF),
gridColor: Color(map['gridColor'] as int? ?? 0xFFFFFFFF),
)
..scale = map['scale'] as double? ?? 1.0
.._offset = Offset(
map['offset.dx'] as double? ?? 0.0,
map['offset.dy'] as double? ?? 0.0,
);
return params;
}
/// Unscaled size of the grid square
/// i.e. the size of the square when scale is 1
final double rawGridSquareSize;
/// Thickness of lines.
final double gridThickness;
/// How many vertical or horizontal lines to draw the marked lines.
final int secondarySquareStep;
/// Grid background color.
final Color backgroundColor;
/// Grid lines color.
final Color gridColor;
/// offset to move the grid
Offset _offset = Offset.zero;
/// Scale of the grid.
double scale = 1;
/// Add listener for scaling
void addOnScaleUpdateListener(void Function(double scale) listener) {
_onScaleUpdateListeners.add(listener);
}
/// Remove listener for scaling
void removeOnScaleUpdateListener(void Function(double scale) listener) {
_onScaleUpdateListeners.remove(listener);
}
final List<void Function(double scale)> _onScaleUpdateListeners = [];
///
set offset(Offset delta) {
_offset += delta;
notifyListeners();
}
///
void setScale(double factor, Offset focalPoint) {
_offset = Offset(
focalPoint.dx * (1 - factor),
focalPoint.dy * (1 - factor),
);
scale = factor;
for (final listener in _onScaleUpdateListeners) {
listener(scale);
}
notifyListeners();
}
/// size of the grid square with scale applied
double get gridSquare => rawGridSquareSize * scale;
///
Offset get offset => _offset;
///
Map<String, dynamic> toMap() {
return {
'offset.dx': _offset.dx,
'offset.dy': _offset.dy,
'scale': scale,
'gridSquare': rawGridSquareSize,
'gridThickness': gridThickness,
'secondarySquareStep': secondarySquareStep,
'backgroundColor': backgroundColor.value,
'gridColor': gridColor.value,
};
}
}
/// Uses a CustomPainter to draw a grid with the given parameters
class GridBackground extends StatelessWidget {
GridBackground({
super.key,
GridBackgroundParams? params,
}) : params = params ?? GridBackgroundParams();
final GridBackgroundParams params;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: params,
builder: (context, _) {
return RepaintBoundary(
child: CustomPaint(
painter: _GridBackgroundPainter(
params: params,
dx: params.offset.dx,
dy: params.offset.dy,
),
),
);
},
);
}
}
class _GridBackgroundPainter extends CustomPainter {
_GridBackgroundPainter({
required this.params,
required this.dx,
required this.dy,
});
final GridBackgroundParams params;
final double dx;
final double dy;
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint();
// Background
paint.color = params.backgroundColor;
canvas.drawRect(
Rect.fromPoints(const Offset(0, 0), Offset(size.width, size.height)),
paint,
);
// grid
paint.color = params.gridColor;
paint.style = PaintingStyle.stroke;
// Calculate the starting points for x and y
double startX = dx % (params.gridSquare * params.secondarySquareStep);
double startY = dy % (params.gridSquare * params.secondarySquareStep);
// Calculate the number of lines to draw outside the visible area
int extraLines = 2;
// Draw vertical lines
for (double x = startX - extraLines * params.gridSquare;
x < size.width + extraLines * params.gridSquare;
x += params.gridSquare) {
paint.strokeWidth = ((x - startX) / params.gridSquare).round() %
params.secondarySquareStep ==
0
? params.gridThickness * 2.0
: params.gridThickness;
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
// Draw horizontal lines
for (double y = startY - extraLines * params.gridSquare;
y < size.height + extraLines * params.gridSquare;
y += params.gridSquare) {
paint.strokeWidth = ((y - startY) / params.gridSquare).round() %
params.secondarySquareStep ==
0
? params.gridThickness * 2.0
: params.gridThickness;
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
@override
bool shouldRepaint(_GridBackgroundPainter oldDelegate) {
debugPrint('shouldRepaint ${oldDelegate.dx} $dx ${oldDelegate.dy} $dy');
return oldDelegate.dx != dx || oldDelegate.dy != dy;
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
/// The arrow tip.
class HandlerWidget extends StatelessWidget {
///
HandlerWidget({
required this.width,
required this.height,
super.key,
this.backgroundColor = Colors.white,
this.borderColor = const Color.fromRGBO(38, 166, 154, 1),
this.icon,
this.isRadius = false,
});
///
final double width;
///
final double height;
///
final Color backgroundColor;
///
final Color borderColor;
///
final Widget? icon;
bool isRadius = false;
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.all(
Radius.circular(isRadius ? 0 : width),
),
border: Border.all(
width: 2,
color: borderColor,
style: BorderStyle.solid,
),
),
child: Padding(
padding: const EdgeInsets.all(4),
child: FittedBox(child: icon),
),
);
}
}

View File

@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/flutter_flow_chart.dart';
import 'package:flutter_flow_chart/src/ui/element_widget.dart';
import 'package:flutter_flow_chart/src/ui/handler_widget.dart';
/// The widget to press and drag to resize the element
class ResizeWidget extends StatefulWidget {
///
ResizeWidget({
required this.comp,
required this.element,
required this.dashboard,
required this.child,
this.handlerColor =const Color.fromRGBO(38, 166, 154, 1),
this.additionnalHeight = 0,
super.key,
});
Color handlerColor = Color.fromRGBO(38, 166, 154, 1);
final ElementWidgetState comp;
///
final Dashboard dashboard;
///
final FlowElement element;
///
final Widget child;
///
final double additionnalHeight;
@override
State<ResizeWidget> createState() => _ResizeWidgetState();
}
class _ResizeWidgetState extends State<ResizeWidget> {
late Size elementStartSize;
late Offset elementStartPosition;
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.element.size.width,
height: widget.element.size.height + widget.additionnalHeight,
child: Stack(
children: [
widget.child,
_handler(Alignment.topLeft),
_handler(Alignment.topRight),
_handler(Alignment.bottomLeft),
_handler(Alignment.bottomRight),
],
),
);
}
Widget _handler(Alignment alignment) {
return Listener(
onPointerDown: (event) {
elementStartSize = widget.element.size;
},
onPointerMove: (event) {
if (alignment == Alignment.topLeft) {
elementStartSize += -event.localDelta;
widget.element.changePosition(
widget.element.position + event.localDelta,
);
} else if (alignment == Alignment.topRight) {
elementStartSize += -Offset(-event.localDelta.dx, event.localDelta.dy);
var l = Offset(0, event.localDelta.dy);
widget.element.changePosition(
widget.element.position + l,
);
} else if (alignment == Alignment.bottomLeft) {
elementStartSize += -Offset(event.localDelta.dx, -event.localDelta.dy);
var l = Offset(event.localDelta.dx, 0);
widget.element.changePosition(
widget.element.position + l,
);
} else { elementStartSize += event.localDelta; }
widget.element.changeSize(elementStartSize);
},
onPointerUp: (event) {
widget.dashboard.setElementResizable(widget.element, false);
},
child: Align(
alignment: alignment,
child: InkWell(
mouseCursor: SystemMouseCursors.resizeColumn,
child: HandlerWidget(
width: 15,
height: 15,
borderColor: Colors.transparent,
backgroundColor: widget.handlerColor,
isRadius: false,
),
),
),
);
}
}

View File

@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_flow_chart/flutter_flow_chart.dart';
/// Widget that use the element properties to display it on the dashboard scene.
class SegmentHandler extends StatefulWidget {
///
const SegmentHandler({
required this.pivot,
required this.dashboard,
super.key,
this.onPivotPressed,
this.onPivotSecondaryPressed,
});
///
final Pivot pivot;
///
final Dashboard dashboard;
///
final void Function(BuildContext context, Pivot position)? onPivotPressed;
///
final void Function(BuildContext context, Pivot position)?
onPivotSecondaryPressed;
@override
State<SegmentHandler> createState() => _SegmentHandlerState();
}
class _SegmentHandlerState extends State<SegmentHandler> {
Offset delta = Offset.zero;
@override
void initState() {
super.initState();
widget.pivot.addListener(_update);
}
@override
void dispose() {
widget.pivot.removeListener(_update);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Transform.translate(
offset:
widget.pivot.pivot - const Offset(5, 5) * widget.dashboard.zoomFactor,
child: Listener(
onPointerDown: (evt) {
delta = evt.delta;
},
child: Draggable(
feedback: const SizedBox(),
onDragUpdate: (details) {
widget.pivot.pivot =
details.globalPosition - delta;
},
onDragEnd: (details) {
widget.pivot.pivot = details.offset;
},
child: GestureDetector(
onTap: () {
widget.onPivotPressed?.call(context, widget.pivot);
},
onSecondaryTap: () {
widget.onPivotSecondaryPressed?.call(context, widget.pivot);
},
child: CircleAvatar(
radius: widget.dashboard.zoomFactor * 5,
foregroundColor: Colors.black,
backgroundColor: Colors.black,
),
),
),
),
);
}
void _update() {
setState(() {});
}
}
///
class Pivot extends ChangeNotifier {
///
Pivot(Offset pivot) : _pivot = pivot;
///
Pivot.fromMap(Map<String, dynamic> map)
: _pivot = Offset(
map['pivot.dx'] as double,
map['pivot.dy'] as double,
);
Offset _pivot;
///
Offset get pivot => _pivot;
///
set pivot(Offset value) {
_pivot = value;
notifyListeners();
}
///
Map<String, dynamic> toMap() {
return <String, dynamic>{
'pivot.dx': _pivot.dx,
'pivot.dy': _pivot.dy,
};
}
///
void setScale(double scale, Offset focalPoint, double factor) {
pivot = ((pivot - focalPoint) / scale) * factor + focalPoint;
}
@override
bool operator ==(Object other) => other is Pivot && other._pivot == _pivot;
@override
int get hashCode => _pivot.hashCode;
}