Compare commits

..

8 Commits

10 changed files with 386 additions and 234 deletions

View File

@ -9,6 +9,7 @@ import 'package:syncrow_web/pages/routines/models/ac/ac_operational_value.dart';
import 'package:syncrow_web/pages/routines/models/device_functions.dart';
import 'package:syncrow_web/pages/routines/widgets/dialog_footer.dart';
import 'package:syncrow_web/pages/routines/widgets/dialog_header.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/helpers/routine_tap_function_helper.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
@ -74,11 +75,26 @@ class ACHelper {
child: _buildFunctionsList(
context: context,
acFunctions: acFunctions,
onFunctionSelected: (functionCode, operationName) =>
context.read<FunctionBloc>().add(SelectFunction(
functionCode: functionCode,
operationName: operationName,
)),
device: device,
onFunctionSelected: (functionCode, operationName) {
RoutineTapFunctionHelper.onTapFunction(
context,
functionCode: functionCode,
functionOperationName: operationName,
functionValueDescription:
selectedFunctionData.valueDescription,
deviceUuid: device?.uuid,
codesToAddIntoFunctionsWithDefaultValue: [
'temp_set',
'temp_current',
],
defaultValue: functionCode == 'temp_set'
? 200
: functionCode == 'temp_current'
? -100
: 0,
);
},
),
),
// Value selector
@ -137,6 +153,7 @@ class ACHelper {
required BuildContext context,
required List<ACFunction> acFunctions,
required Function(String, String) onFunctionSelected,
required AllDevicesModel? device,
}) {
return ListView.separated(
shrinkWrap: false,

View File

@ -131,7 +131,12 @@ class _CeilingSensorDialogState extends State<CeilingSensorDialog> {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
CpsFunctionsList(cpsFunctions: _cpsFunctions),
CpsFunctionsList(
cpsFunctions: _cpsFunctions,
device: widget.device,
selectedFunctionData: selectedFunctionData,
dialogType: widget.dialogType,
),
if (state.selectedFunction != null)
Expanded(
child: isToggleFunction

View File

@ -1,14 +1,24 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/routines/bloc/functions_bloc/functions_bloc_bloc.dart';
import 'package:syncrow_web/pages/device_managment/all_devices/models/devices_model.dart';
import 'package:syncrow_web/pages/routines/models/ceiling_presence_sensor_functions.dart';
import 'package:syncrow_web/pages/routines/models/device_functions.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialog_function_list_tile.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/helpers/routine_tap_function_helper.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class CpsFunctionsList extends StatelessWidget {
const CpsFunctionsList({required this.cpsFunctions, super.key});
const CpsFunctionsList({
required this.cpsFunctions,
required this.device,
required this.selectedFunctionData,
required this.dialogType,
super.key,
});
final List<CpsFunctions> cpsFunctions;
final AllDevicesModel? device;
final DeviceFunctionData? selectedFunctionData;
final String dialogType;
@override
Widget build(BuildContext context) {
@ -26,12 +36,27 @@ class CpsFunctionsList extends StatelessWidget {
return RoutineDialogFunctionListTile(
iconPath: function.icon,
operationName: function.operationName,
onTap: () => context.read<FunctionBloc>().add(
SelectFunction(
functionCode: function.code,
operationName: function.operationName,
),
),
onTap: () => RoutineTapFunctionHelper.onTapFunction(
context,
functionCode: function.code,
functionOperationName: function.operationName,
functionValueDescription: selectedFunctionData?.valueDescription,
deviceUuid: device?.uuid,
codesToAddIntoFunctionsWithDefaultValue: [
'static_max_dis',
'presence_reference',
'moving_reference',
'perceptual_boundary',
'moving_boundary',
'moving_rigger_time',
'moving_static_time',
'none_body_time',
'moving_max_dis',
'moving_range',
'presence_range',
if (dialogType == "IF") 'sensitivity',
],
),
);
},
),

View File

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/routines/bloc/functions_bloc/functions_bloc_bloc.dart';
import 'package:syncrow_web/pages/routines/models/device_functions.dart';
abstract final class RoutineTapFunctionHelper {
const RoutineTapFunctionHelper._();
static void onTapFunction(
BuildContext context, {
required String functionCode,
required String functionOperationName,
required String? functionValueDescription,
required String? deviceUuid,
required List<String> codesToAddIntoFunctionsWithDefaultValue,
int defaultValue = 0,
}) {
final functionsBloc = context.read<FunctionBloc>();
functionsBloc.add(
SelectFunction(
functionCode: functionCode,
operationName: functionOperationName,
),
);
final addedFunctions = functionsBloc.state.addedFunctions;
final isFunctionAlreadyAdded = addedFunctions.any(
(e) => e.functionCode == functionCode,
);
final shouldAddFunction =
codesToAddIntoFunctionsWithDefaultValue.contains(functionCode);
if (!isFunctionAlreadyAdded && shouldAddFunction) {
context.read<FunctionBloc>().add(
AddFunction(
functionData: DeviceFunctionData(
entityId: deviceUuid ?? '',
functionCode: functionCode,
operationName: functionOperationName,
value: defaultValue,
condition: '==',
valueDescription: functionValueDescription,
),
),
);
}
}
}

View File

@ -11,6 +11,7 @@ import 'package:syncrow_web/pages/routines/models/gang_switches/one_gang_switch/
import 'package:syncrow_web/pages/routines/models/gang_switches/switch_operational_value.dart';
import 'package:syncrow_web/pages/routines/widgets/dialog_footer.dart';
import 'package:syncrow_web/pages/routines/widgets/dialog_header.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/helpers/routine_tap_function_helper.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
@ -24,21 +25,23 @@ class OneGangSwitchHelper {
required String uniqueCustomId,
required bool removeComparetors,
}) async {
List<BaseSwitchFunction> oneGangFunctions = functions.whereType<BaseSwitchFunction>().toList();
List<BaseSwitchFunction> oneGangFunctions =
functions.whereType<BaseSwitchFunction>().toList();
return showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext context) {
return BlocProvider(
create: (_) => FunctionBloc()..add(InitializeFunctions(deviceSelectedFunctions ?? [])),
create: (_) => FunctionBloc()
..add(InitializeFunctions(deviceSelectedFunctions ?? [])),
child: AlertDialog(
contentPadding: EdgeInsets.zero,
content: BlocBuilder<FunctionBloc, FunctionBlocState>(
builder: (context, state) {
final selectedFunction = state.selectedFunction;
final selectedOperationName = state.selectedOperationName;
final selectedFunctionData =
state.addedFunctions.firstWhere((f) => f.functionCode == selectedFunction,
final selectedFunctionData = state.addedFunctions
.firstWhere((f) => f.functionCode == selectedFunction,
orElse: () => DeviceFunctionData(
entityId: '',
functionCode: selectedFunction ?? '',
@ -84,12 +87,19 @@ class OneGangSwitchHelper {
size: 16,
color: ColorsManager.textGray,
),
onTap: () {
context.read<FunctionBloc>().add(SelectFunction(
functionCode: function.code,
operationName: function.operationName,
));
},
onTap: () =>
RoutineTapFunctionHelper.onTapFunction(
context,
functionCode: function.code,
functionOperationName:
function.operationName,
functionValueDescription:
selectedFunctionData.valueDescription,
deviceUuid: device?.uuid,
codesToAddIntoFunctionsWithDefaultValue: [
'countdown_1',
],
),
);
},
),
@ -220,11 +230,11 @@ class OneGangSwitchHelper {
selectedFunctionData,
),
const SizedBox(height: 20),
_buildCountDownDisplay(
context, initialValue, device, operationName, selectedFunctionData, selectCode),
_buildCountDownDisplay(context, initialValue, device, operationName,
selectedFunctionData, selectCode),
const SizedBox(height: 20),
_buildCountDownSlider(
context, initialValue, device, operationName, selectedFunctionData, selectCode),
_buildCountDownSlider(context, initialValue, device, operationName,
selectedFunctionData, selectCode),
],
);
}
@ -314,9 +324,10 @@ class OneGangSwitchHelper {
value: (initialValue ?? 0).toDouble(),
min: operationalValues.minValue?.toDouble() ?? 0.0,
max: operationalValues.maxValue?.toDouble() ?? 0.0,
divisions: (((operationalValues.maxValue ?? 0) - (operationalValues.minValue ?? 0)) /
(operationalValues.stepValue ?? 1))
.round(),
divisions:
(((operationalValues.maxValue ?? 0) - (operationalValues.minValue ?? 0)) /
(operationalValues.stepValue ?? 1))
.round(),
onChanged: (value) {
context.read<FunctionBloc>().add(
AddFunction(
@ -368,7 +379,9 @@ class OneGangSwitchHelper {
trailing: Icon(
isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
size: 24,
color: isSelected ? ColorsManager.primaryColorWithOpacity : ColorsManager.textGray,
color: isSelected
? ColorsManager.primaryColorWithOpacity
: ColorsManager.textGray,
),
onTap: () {
if (!isSelected) {

View File

@ -10,6 +10,7 @@ import 'package:syncrow_web/pages/routines/models/gang_switches/base_switch_func
import 'package:syncrow_web/pages/routines/models/gang_switches/switch_operational_value.dart';
import 'package:syncrow_web/pages/routines/widgets/dialog_footer.dart';
import 'package:syncrow_web/pages/routines/widgets/dialog_header.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/helpers/routine_tap_function_helper.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
@ -85,15 +86,21 @@ class ThreeGangSwitchHelper {
size: 16,
color: ColorsManager.textGray,
),
onTap: () {
context
.read<FunctionBloc>()
.add(SelectFunction(
functionCode: function.code,
operationName:
function.operationName,
));
},
onTap: () =>
RoutineTapFunctionHelper.onTapFunction(
context,
functionCode: function.code,
functionOperationName:
function.operationName,
functionValueDescription:
selectedFunctionData.valueDescription,
deviceUuid: device?.uuid,
codesToAddIntoFunctionsWithDefaultValue: [
'countdown_1',
'countdown_2',
'countdown_3',
],
),
);
},
),
@ -183,8 +190,7 @@ class ThreeGangSwitchHelper {
);
}
final selectedFn =
switchFunctions.firstWhere((f) => f.code == selectedFunction);
final selectedFn = switchFunctions.firstWhere((f) => f.code == selectedFunction);
final values = selectedFn.getOperationalValues();
return _buildOperationalValuesList(
@ -266,8 +272,7 @@ class ThreeGangSwitchHelper {
minHeight: 40.0,
minWidth: 40.0,
),
isSelected:
conditions.map((c) => c == (currentCondition ?? "==")).toList(),
isSelected: conditions.map((c) => c == (currentCondition ?? "==")).toList(),
children: conditions.map((c) => Text(c)).toList(),
);
}
@ -316,10 +321,10 @@ class ThreeGangSwitchHelper {
value: (initialValue ?? 0).toDouble(),
min: operationalValues.minValue?.toDouble() ?? 0.0,
max: operationalValues.maxValue?.toDouble() ?? 0.0,
divisions: (((operationalValues.maxValue ?? 0) -
(operationalValues.minValue ?? 0)) /
(operationalValues.stepValue ?? 1))
.round(),
divisions:
(((operationalValues.maxValue ?? 0) - (operationalValues.minValue ?? 0)) /
(operationalValues.stepValue ?? 1))
.round(),
onChanged: (value) {
context.read<FunctionBloc>().add(
AddFunction(
@ -369,9 +374,7 @@ class ThreeGangSwitchHelper {
style: context.textTheme.bodyMedium,
),
trailing: Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
size: 24,
color: isSelected
? ColorsManager.primaryColorWithOpacity
@ -387,8 +390,7 @@ class ThreeGangSwitchHelper {
operationName: operationName,
value: value.value,
condition: selectedFunctionData?.condition,
valueDescription:
selectedFunctionData?.valueDescription,
valueDescription: selectedFunctionData?.valueDescription,
),
),
);

View File

@ -10,6 +10,7 @@ import 'package:syncrow_web/pages/routines/models/gang_switches/base_switch_func
import 'package:syncrow_web/pages/routines/models/gang_switches/switch_operational_value.dart';
import 'package:syncrow_web/pages/routines/widgets/dialog_footer.dart';
import 'package:syncrow_web/pages/routines/widgets/dialog_header.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/helpers/routine_tap_function_helper.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
@ -85,15 +86,20 @@ class TwoGangSwitchHelper {
size: 16,
color: ColorsManager.textGray,
),
onTap: () {
context
.read<FunctionBloc>()
.add(SelectFunction(
functionCode: function.code,
operationName:
function.operationName,
));
},
onTap: () =>
RoutineTapFunctionHelper.onTapFunction(
context,
functionCode: function.code,
functionOperationName:
function.operationName,
functionValueDescription:
selectedFunctionData.valueDescription,
deviceUuid: device?.uuid,
codesToAddIntoFunctionsWithDefaultValue: [
'countdown_1',
'countdown_2',
],
),
);
},
),
@ -167,8 +173,7 @@ class TwoGangSwitchHelper {
required String operationName,
required bool removeComparetors,
}) {
if (selectedFunction == 'countdown_1' ||
selectedFunction == 'countdown_2') {
if (selectedFunction == 'countdown_1' || selectedFunction == 'countdown_2') {
final initialValue = selectedFunctionData?.value ?? 0;
return _buildTemperatureSelector(
context: context,
@ -182,8 +187,7 @@ class TwoGangSwitchHelper {
);
}
final selectedFn =
switchFunctions.firstWhere((f) => f.code == selectedFunction);
final selectedFn = switchFunctions.firstWhere((f) => f.code == selectedFunction);
final values = selectedFn.getOperationalValues();
return _buildOperationalValuesList(
@ -265,8 +269,7 @@ class TwoGangSwitchHelper {
minHeight: 40.0,
minWidth: 40.0,
),
isSelected:
conditions.map((c) => c == (currentCondition ?? "==")).toList(),
isSelected: conditions.map((c) => c == (currentCondition ?? "==")).toList(),
children: conditions.map((c) => Text(c)).toList(),
);
}
@ -315,10 +318,10 @@ class TwoGangSwitchHelper {
value: (initialValue ?? 0).toDouble(),
min: operationalValues.minValue?.toDouble() ?? 0.0,
max: operationalValues.maxValue?.toDouble() ?? 0.0,
divisions: (((operationalValues.maxValue ?? 0) -
(operationalValues.minValue ?? 0)) /
(operationalValues.stepValue ?? 1))
.round(),
divisions:
(((operationalValues.maxValue ?? 0) - (operationalValues.minValue ?? 0)) /
(operationalValues.stepValue ?? 1))
.round(),
onChanged: (value) {
context.read<FunctionBloc>().add(
AddFunction(
@ -368,9 +371,7 @@ class TwoGangSwitchHelper {
style: context.textTheme.bodyMedium,
),
trailing: Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
size: 24,
color: isSelected
? ColorsManager.primaryColorWithOpacity
@ -386,8 +387,7 @@ class TwoGangSwitchHelper {
operationName: operationName,
value: value.value,
condition: selectedFunctionData?.condition,
valueDescription:
selectedFunctionData?.valueDescription,
valueDescription: selectedFunctionData?.valueDescription,
),
),
);

View File

@ -8,6 +8,7 @@ import 'package:syncrow_web/pages/routines/models/device_functions.dart';
import 'package:syncrow_web/pages/routines/models/wps/wps_functions.dart';
import 'package:syncrow_web/pages/routines/widgets/dialog_footer.dart';
import 'package:syncrow_web/pages/routines/widgets/dialog_header.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/helpers/routine_tap_function_helper.dart';
import 'package:syncrow_web/pages/routines/widgets/routine_dialogs/wall_sensor/wps_value_selector_widget.dart';
import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart';
@ -111,13 +112,23 @@ class _WallPresenceSensorState extends State<WallPresenceSensor> {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildFunctionList(context),
_buildFunctionList(context, state),
if (state.selectedFunction != null) _buildValueSelector(context, state),
],
);
}
Widget _buildFunctionList(BuildContext context) {
Widget _buildFunctionList(BuildContext context, FunctionBlocState state) {
final selectedFunction = state.selectedFunction;
final selectedFunctionData = state.addedFunctions.firstWhere(
(f) => f.functionCode == selectedFunction,
orElse: () => DeviceFunctionData(
entityId: '',
functionCode: selectedFunction ?? '',
operationName: '',
value: null,
),
);
return SizedBox(
width: 360,
child: ListView.separated(
@ -149,12 +160,18 @@ class _WallPresenceSensorState extends State<WallPresenceSensor> {
size: 16,
color: ColorsManager.textGray,
),
onTap: () => context.read<FunctionBloc>().add(
SelectFunction(
functionCode: function.code,
operationName: function.operationName,
),
),
onTap: () => RoutineTapFunctionHelper.onTapFunction(
context,
functionCode: function.code,
functionOperationName: function.operationName,
functionValueDescription: selectedFunctionData.valueDescription,
deviceUuid: widget.device?.uuid,
codesToAddIntoFunctionsWithDefaultValue: [
'dis_current',
'presence_time',
'illuminance_value',
],
),
);
},
),

View File

@ -1,4 +1,6 @@
// Flutter imports
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -336,6 +338,7 @@ class _CommunityStructureAreaState extends State<CommunityStructureArea> {
}
spaces.add(newSpace);
_updateNodePosition(newSpace, newSpace.position);
realignTree();
});
},
);
@ -450,7 +453,6 @@ class _CommunityStructureAreaState extends State<CommunityStructureArea> {
void _saveSpaces() {
if (widget.selectedCommunity == null) {
debugPrint("No community selected for saving spaces.");
return;
}
@ -530,35 +532,83 @@ class _CommunityStructureAreaState extends State<CommunityStructureArea> {
}
Offset getBalancedChildPosition(SpaceModel parent) {
int totalSiblings = parent.children.length + 1;
double totalWidth = (totalSiblings - 1) * 250; // Horizontal spacing
double startX = parent.position.dx - (totalWidth / 2);
const double nodeWidth = 200;
const double verticalGap = 180;
Offset position = Offset(startX + (parent.children.length * 250), parent.position.dy + 180);
if (parent.children.isEmpty) {
// First child → exactly center
return Offset(parent.position.dx, parent.position.dy + verticalGap);
} else {
// More children → arrange them spaced horizontally
double totalWidth = (parent.children.length) * (nodeWidth + 60);
double startX = parent.position.dx - (totalWidth / 2);
// Check for overlaps & adjust
while (spaces.any((s) => (s.position - position).distance < 250)) {
position = Offset(position.dx + 250, position.dy);
double childX = startX + (parent.children.length * (nodeWidth + 60));
return Offset(childX, parent.position.dy + verticalGap);
}
return position;
}
void realignTree() {
void updatePositions(SpaceModel node, double x, double y) {
node.position = Offset(x, y);
const double nodeWidth = 200;
const double nodeHeight = 100;
const double horizontalGap = 60;
const double verticalGap = 180;
const double rootGap = 400; // extra space between different roots
int numChildren = node.children.length;
double childStartX = x - ((numChildren - 1) * 250) / 2;
double canvasRightEdge = 1000;
double canvasBottomEdge = 1000;
for (int i = 0; i < numChildren; i++) {
updatePositions(node.children[i], childStartX + (i * 250), y + 180);
double calculateSubtreeWidth(SpaceModel node) {
if (node.children.isEmpty) return nodeWidth;
double totalWidth = 0;
for (var child in node.children) {
totalWidth += calculateSubtreeWidth(child) + horizontalGap;
}
return totalWidth - horizontalGap;
}
void layoutSubtree(SpaceModel node, double startX, double y) {
double subtreeWidth = calculateSubtreeWidth(node);
double centerX = startX + subtreeWidth / 2 - nodeWidth / 2;
node.position = Offset(centerX, y);
canvasRightEdge = max(canvasRightEdge, centerX + nodeWidth);
canvasBottomEdge = max(canvasBottomEdge, y + nodeHeight);
if (node.children.length == 1) {
final child = node.children.first;
layoutSubtree(child, centerX, y + verticalGap);
} else {
double childX = startX;
for (var child in node.children) {
double childWidth = calculateSubtreeWidth(child);
layoutSubtree(child, childX, y + verticalGap);
childX += childWidth + horizontalGap;
}
}
}
if (spaces.isNotEmpty) {
updatePositions(spaces.first, spaces.first.position.dx, spaces.first.position.dy);
// ⚡ New: layout each root separately
final List<SpaceModel> roots = spaces
.where((s) =>
s.parent == null &&
s.status != SpaceStatus.deleted &&
s.status != SpaceStatus.parentDeleted)
.toList();
double currentX = 100; // start some margin from left
double currentY = 100; // top margin
for (var root in roots) {
layoutSubtree(root, currentX, currentY);
double rootWidth = calculateSubtreeWidth(root);
currentX += rootWidth + rootGap;
}
setState(() {
canvasWidth = canvasRightEdge + 400;
canvasHeight = canvasBottomEdge + 400;
});
}
void _onDuplicate(BuildContext parentContext) {
@ -642,63 +692,19 @@ class _CommunityStructureAreaState extends State<CommunityStructureArea> {
}
void _duplicateSpace(SpaceModel space) {
final Map<SpaceModel, SpaceModel> originalToDuplicate = {};
double horizontalGap = 250.0; // Increased spacing
double verticalGap = 180.0; // Adjusted for better visualization
final double horizontalGap = 250.0;
final double verticalGap = 180.0;
final double nodeWidth = 200;
final double nodeHeight = 100;
final double breathingSpace = 300.0; // extra gap after original tree
print("🟢 Duplicating: ${space.name}");
/// **Find a new position ensuring no overlap**
Offset getBalancedChildPosition(SpaceModel parent) {
int totalSiblings = parent.children.length + 1;
double totalWidth = (totalSiblings - 1) * horizontalGap;
double startX = parent.position.dx - (totalWidth / 2);
Offset position = Offset(
startX + (parent.children.length * horizontalGap), parent.position.dy + verticalGap);
// **Check for overlaps & adjust**
while (spaces.any((s) => (s.position - position).distance < horizontalGap)) {
position = Offset(position.dx + horizontalGap, position.dy);
}
print("🔹 New position for ${parent.name}: (${position.dx}, ${position.dy})");
return position;
}
/// **Realign the entire tree after duplication**
void realignTree() {
void updatePositions(SpaceModel node, double x, double y) {
node.position = Offset(x, y);
print("✅ Adjusted ${node.name} to (${x}, ${y})");
int numChildren = node.children.length;
double childStartX = x - ((numChildren - 1) * horizontalGap) / 2;
for (int i = 0; i < numChildren; i++) {
updatePositions(node.children[i], childStartX + (i * horizontalGap), y + verticalGap);
}
}
if (spaces.isNotEmpty) {
print("🔄 Realigning tree...");
updatePositions(spaces.first, spaces.first.position.dx, spaces.first.position.dy);
}
}
/// **Recursive duplication logic**
/// Helper to recursively duplicate a node and its children
SpaceModel duplicateRecursive(SpaceModel original, SpaceModel? duplicatedParent) {
Offset newPosition = duplicatedParent == null
? Offset(original.position.dx + horizontalGap, original.position.dy)
: getBalancedChildPosition(duplicatedParent);
final duplicatedName = SpaceHelper.generateUniqueSpaceName(original.name, spaces);
print(
"🟡 Duplicating ${original.name}${duplicatedName} at (${newPosition.dx}, ${newPosition.dy})");
final duplicated = SpaceModel(
name: duplicatedName,
icon: original.icon,
position: newPosition,
position: Offset.zero,
isPrivate: original.isPrivate,
children: [],
status: SpaceStatus.newSpace,
@ -708,28 +714,20 @@ class _CommunityStructureAreaState extends State<CommunityStructureArea> {
tags: original.tags,
);
setState(() {
spaces.add(duplicated);
_updateNodePosition(duplicated, duplicated.position);
spaces.add(duplicated);
if (duplicatedParent != null) {
final newConnection = Connection(
startSpace: duplicatedParent,
endSpace: duplicated,
direction: "down",
);
connections.add(newConnection);
duplicated.incomingConnection = newConnection;
duplicatedParent.addOutgoingConnection(newConnection);
duplicatedParent.children.add(duplicated);
print("🔗 Created connection: ${duplicatedParent.name}${duplicated.name}");
}
if (duplicatedParent != null) {
final newConnection = Connection(
startSpace: duplicatedParent,
endSpace: duplicated,
direction: "down",
);
connections.add(newConnection);
duplicated.incomingConnection = newConnection;
duplicatedParent.addOutgoingConnection(newConnection);
duplicatedParent.children.add(duplicated);
}
// **Recalculate the whole tree to avoid overlaps**
realignTree();
});
// Recursively duplicate children
for (var child in original.children) {
duplicateRecursive(child, duplicated);
}
@ -737,21 +735,49 @@ class _CommunityStructureAreaState extends State<CommunityStructureArea> {
return duplicated;
}
/// **Handle root duplication**
if (space.parent == null) {
print("🟠 Duplicating root node: ${space.name}");
SpaceModel duplicatedRoot = duplicateRecursive(space, null);
/// Layout a subtree rooted at node
void layoutSubtree(SpaceModel node, double startX, double startY) {
double calculateSubtreeWidth(SpaceModel n) {
if (n.children.isEmpty) return nodeWidth;
double width = 0;
for (var child in n.children) {
width += calculateSubtreeWidth(child) + horizontalGap;
}
return width - horizontalGap;
}
setState(() {
spaces.add(duplicatedRoot);
realignTree();
});
void assignPositions(SpaceModel n, double x, double y) {
double subtreeWidth = calculateSubtreeWidth(n);
double centerX = x + subtreeWidth / 2 - nodeWidth / 2;
n.position = Offset(centerX, y);
print("✅ Root duplication successful: ${duplicatedRoot.name}");
} else {
duplicateRecursive(space, space.parent);
if (n.children.length == 1) {
assignPositions(n.children.first, centerX, y + verticalGap);
} else {
double childX = x;
for (var child in n.children) {
double childWidth = calculateSubtreeWidth(child);
assignPositions(child, childX, y + verticalGap);
childX += childWidth + horizontalGap;
}
}
}
double totalSubtreeWidth = calculateSubtreeWidth(node);
assignPositions(node, startX, startY);
}
print("🟢 Finished duplication process for: ${space.name}");
/// Actual duplication process
setState(() {
if (space.parent == null) {
// Duplicating a ROOT node
SpaceModel duplicatedRoot = duplicateRecursive(space, null);
realignTree();
} else {
// Duplicating a CHILD node inside its parent
SpaceModel duplicated = duplicateRecursive(space, space.parent);
realignTree();
}
});
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/common/widgets/empty_search_result_widget.dart';
import 'package:syncrow_web/common/widgets/search_bar.dart';
import 'package:syncrow_web/common/widgets/sidebar_communities_list.dart';
import 'package:syncrow_web/pages/space_tree/bloc/space_tree_bloc.dart';
import 'package:syncrow_web/pages/spaces_management/all_spaces/bloc/space_management_bloc.dart';
import 'package:syncrow_web/pages/spaces_management/all_spaces/bloc/space_management_event.dart';
import 'package:syncrow_web/pages/spaces_management/all_spaces/model/community_model.dart';
@ -15,6 +16,8 @@ import 'package:syncrow_web/pages/spaces_management/structure_selector/bloc/cent
import 'package:syncrow_web/pages/spaces_management/structure_selector/bloc/center_body_event.dart';
import 'package:syncrow_web/utils/style.dart';
import '../../../space_tree/bloc/space_tree_event.dart';
class SidebarWidget extends StatefulWidget {
final List<CommunityModel> communities;
final String? selectedSpaceUuid;
@ -40,13 +43,31 @@ class _SidebarWidgetState extends State<SidebarWidget> {
@override
void initState() {
_selectedId = widget.selectedSpaceUuid;
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
_selectedId = widget.selectedSpaceUuid;
super.initState();
}
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 100) {
// Trigger pagination event
final bloc = context.read<SpaceTreeBloc>();
if (!bloc.state.paginationIsLoading && bloc.state.paginationModel?.hasNext == true) {
bloc.add(
PaginationEvent(
bloc.state.paginationModel!,
bloc.state.communityList,
),
);
}
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
@ -59,38 +80,6 @@ class _SidebarWidgetState extends State<SidebarWidget> {
super.didUpdateWidget(oldWidget);
}
List<CommunityModel> _filteredCommunities() {
if (_searchQuery.isEmpty) {
_selectedSpaceUuid = null;
return widget.communities;
}
return widget.communities.where((community) {
final containsQueryInCommunity =
community.name.toLowerCase().contains(_searchQuery.toLowerCase());
final containsQueryInSpaces = community.spaces.any((space) =>
_containsQuery(space: space, query: _searchQuery.toLowerCase()));
return containsQueryInCommunity || containsQueryInSpaces;
}).toList();
}
bool _containsQuery({
required SpaceModel space,
required String query,
}) {
final matchesSpace = space.name.toLowerCase().contains(query);
final matchesChildren = space.children.any(
(child) => _containsQuery(space: child, query: query),
);
if (matchesSpace || matchesChildren) {
_selectedSpaceUuid = space.uuid;
}
return matchesSpace || matchesChildren;
}
bool _isSpaceOrChildSelected(SpaceModel space) {
final isSpaceSelected = _selectedSpaceUuid == space.uuid;
final anySubSpaceIsSelected = space.children.any(_isSpaceOrChildSelected);
@ -101,7 +90,10 @@ class _SidebarWidgetState extends State<SidebarWidget> {
@override
Widget build(BuildContext context) {
final filteredCommunities = _filteredCommunities();
final spaceTreeState = context.watch<SpaceTreeBloc>().state;
final filteredCommunities = spaceTreeState.isSearching
? spaceTreeState.filteredCommunity
: spaceTreeState.communityList;
return Container(
width: _width,
@ -112,7 +104,13 @@ class _SidebarWidgetState extends State<SidebarWidget> {
children: [
SidebarHeader(onAddCommunity: _onAddCommunity),
CustomSearchBar(
onSearchChanged: (query) => setState(() => _searchQuery = query),
onSearchChanged: (query) {
setState(() {
_searchQuery = query;
});
context.read<SpaceTreeBloc>().add(SearchQueryEvent(query));
},
),
const SizedBox(height: 16),
Expanded(
@ -120,14 +118,18 @@ class _SidebarWidgetState extends State<SidebarWidget> {
visible: filteredCommunities.isNotEmpty,
replacement: const EmptySearchResultWidget(),
child: SidebarCommunitiesList(
scrollController: _scrollController,
onScrollToEnd: () {},
communities: filteredCommunities,
itemBuilder: (context, index) => _buildCommunityTile(
context,
filteredCommunities[index],
),
),
scrollController: _scrollController,
onScrollToEnd: () {},
communities: filteredCommunities,
itemBuilder: (context, index) {
if (index == filteredCommunities.length) {
return const Padding(
padding: EdgeInsets.all(8.0),
child: Center(child: CircularProgressIndicator()),
);
}
return _buildCommunityTile(context, filteredCommunities[index]);
}),
),
),
],
@ -205,9 +207,8 @@ class _SidebarWidgetState extends State<SidebarWidget> {
);
}
void _onAddCommunity() => _selectedId?.isNotEmpty ?? true
? _clearSelection()
: _showCreateCommunityDialog();
void _onAddCommunity() =>
_selectedId?.isNotEmpty ?? true ? _clearSelection() : _showCreateCommunityDialog();
void _clearSelection() {
setState(() => _selectedId = '');