diff --git a/flutter_front/lib/features/charts/domain/entities/chart_config.dart b/flutter_front/lib/features/charts/domain/entities/chart_config.dart new file mode 100644 index 0000000..51ea42a --- /dev/null +++ b/flutter_front/lib/features/charts/domain/entities/chart_config.dart @@ -0,0 +1,77 @@ +// lib/features/charts/domain/entities/chart_config.dart + +enum AggregationType { + min, + max, + mean, +} + +enum TimeRange { + day, + halfDay, + hour, + fifteenMinutes, + custom, +} + +String getInfluxString(TimeRange range) { + switch (range) { + case TimeRange.day: + return '-1d'; + case TimeRange.halfDay: + return '-12h'; + case TimeRange.hour: + return '-1h'; + case TimeRange.fifteenMinutes: + return '-15m'; + case TimeRange.custom: + return '-5s'; // значение по умолчанию + } +} + + +class ChartConfiguration { + final bool showWarnings; + final bool showAnomalies; + final Set selectedAggregations; + final TimeRange timeRange; + final DateTime customStartDate; + final DateTime customEndDate; + final Duration pointsDistance; + final bool realTime; + + ChartConfiguration({ + this.showWarnings = false, + this.showAnomalies = false, + this.selectedAggregations = const {AggregationType.mean}, + this.timeRange = TimeRange.hour, + DateTime? customStartDate, + DateTime? customEndDate, + this.pointsDistance = const Duration(minutes: 1), + this.realTime = false, + }) : + customStartDate = customStartDate ?? DateTime.now().subtract(const Duration(days: 7)), + customEndDate = customEndDate ?? DateTime.now(); + + ChartConfiguration copyWith({ + bool? showWarnings, + bool? showAnomalies, + Set? selectedAggregations, + TimeRange? timeRange, + DateTime? customStartDate, + DateTime? customEndDate, + Duration? pointsDistance, + bool? realTime, + }) { + return ChartConfiguration( + showWarnings: showWarnings ?? this.showWarnings, + showAnomalies: showAnomalies ?? this.showAnomalies, + selectedAggregations: selectedAggregations ?? this.selectedAggregations, + timeRange: timeRange ?? this.timeRange, + customStartDate: customStartDate ?? this.customStartDate, + customEndDate: customEndDate ?? this.customEndDate, + pointsDistance: pointsDistance ?? this.pointsDistance, + realTime: realTime ?? this.realTime, + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/charts/domain/usecases/get_charts_data_use_case.dart b/flutter_front/lib/features/charts/domain/usecases/get_charts_data_use_case.dart new file mode 100644 index 0000000..8097999 --- /dev/null +++ b/flutter_front/lib/features/charts/domain/usecases/get_charts_data_use_case.dart @@ -0,0 +1,55 @@ +import 'package:clean_architecture/features/charts/domain/entities/chart_config.dart'; +import 'package:clean_architecture/shared/data/models/minichart_data_model.dart'; +import 'package:dartz/dartz.dart'; + +import '../../../../core/error/failure.dart'; +import '../../../../core/types/influx_formater.dart'; +import '../../../../core/usecases/usecase.dart'; +import '../../../../shared/domain/repositories/influxdb_repository.dart'; + +class GetChartsDataUseCase implements UseCase { + final InfluxdbRepository influxRepository; + + GetChartsDataUseCase(this.influxRepository); + + @override + Future> call(GetChartsDataUseCaseParams params) async { + final res = await influxRepository.getLiveChartsData( + params.topics, + params.getInterval(), + params.getStart(), + params.getEnd()); + return res.fold((f) => Left(f), + (data) => Right(data)); + } +} + +class GetChartsDataUseCaseParams{ + final TimeRange period; + final DateTime start; + final DateTime end; + final Duration interval; + + final Map topics; + GetChartsDataUseCaseParams({ + required this.period, + required this.start, + required this.end, + required this.interval, + required this.topics + }); + + String getStart() { + if(period == TimeRange.custom) return formatDateTimeForInflux(start); + return getInfluxString(period); + } + + String getEnd() { + if(period == TimeRange.custom) return formatDateTimeForInflux(end); + return "now()"; + } + + String getInterval() { + return '${interval.inSeconds}s'; + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/charts/presentation/bloc/charts_bloc.dart b/flutter_front/lib/features/charts/presentation/bloc/charts_bloc.dart new file mode 100644 index 0000000..35246eb --- /dev/null +++ b/flutter_front/lib/features/charts/presentation/bloc/charts_bloc.dart @@ -0,0 +1,254 @@ +import 'dart:async'; + +import 'package:clean_architecture/features/charts/domain/usecases/get_charts_data_use_case.dart'; +import 'package:clean_architecture/shared/domain/entities/equipment/equipment_entity.dart'; +import 'package:clean_architecture/shared/domain/usecases/get_multiple_chosen_equipment.dart'; +import 'package:clean_architecture/shared/domain/usecases/set_multiple_chosen_equipment.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/types/optional.dart'; +import '../../../../shared/data/models/minichart_data_model.dart'; +import '../../../../shared/domain/entities/equipment/equipment_list_entity.dart'; +import '../../../../shared/domain/usecases/get_equipment_usecase.dart'; +import '../../../../shared/domain/usecases/no_params.dart'; +import '../../../settings/presentation/widgets/settings_message.dart'; +import '../../domain/entities/chart_config.dart'; + +part 'charts_event.dart'; +part 'charts_state.dart'; + +class ChartsBloc extends Bloc { + final GetEquipmentUseCase getEquipmentList; + final GetMultipleChosenEquipmentUseCase getSelectedEquipment; + final SetMultipleChosenEquipmentUseCase saveSelectedEquipment; + final GetChartsDataUseCase getChartsDataUseCase; + Timer? _realTimeTimer; + + ChartsBloc({ + required this.getEquipmentList, + required this.getSelectedEquipment, + required this.saveSelectedEquipment, + required this.getChartsDataUseCase, + }) : super(ChartsInitial()) { + on(_onInitializeCharts); + on(_onSaveSelectedEquipment); + on(_onSaveSelectedParameters); + on(_onUpdateConfiguration); + on(_onFetchChartsData); + on(_onFetchRealTimeData); + } + + @override + Future close() { + _realTimeTimer?.cancel(); + return super.close(); + } + + + Future _onInitializeCharts( + InitializeCharts event, + Emitter emit, + ) async { + emit(ChartsLoading()); + + final selectedEquipmentResult = await getSelectedEquipment(NoParams()); + final equipmentListResult = await getEquipmentList(NoParams()); + + selectedEquipmentResult.fold( + (failure) => emit(ChartsError("Не удалось загрузить выбранное оборудование")), + (selectedEquipment) { + equipmentListResult.fold( + (failure) => + emit(ChartsError("Не удалось загрузить список оборудования")), + (equipmentList) => emit(ChartsLoaded( + equipmentList: equipmentList, + selectedEquipmentKeys: selectedEquipment, + selectedParameterKeys: null, configuration: ChartConfiguration(), + )), + ); + }, + ); + } + + Future _onSaveSelectedEquipment( + SaveSelectedEquipmentEvent event, + Emitter emit, + ) async { + if (state is ChartsLoaded) { + final currentState = state as ChartsLoaded; + emit(ChartsSavingEquipment(currentState)); + + + final result = await saveSelectedEquipment(event.equipmentKeys); + + result.fold( + (failure) => emit(currentState.copyWith( + message: BottomMessage( + "Не удалось сохранить выбранное оборудование", + isError: true, + ), + )), + (_) => emit(currentState.copyWith( + selectedEquipmentKeys: Optional(event.equipmentKeys), + selectedParameterKeys: const Optional(null), // Сбрасываем параметры при смене оборудования + )), + ); + } + } + + void _onSaveSelectedParameters( + SaveSelectedParametersEvent event, + Emitter emit, + ) { + if (state is ChartsLoaded) { + final currentState = state as ChartsLoaded; + emit(currentState.copyWith( + selectedParameterKeys: Optional(event.parameterKeys), + )); + } + } + + void _onUpdateConfiguration( + UpdateChartConfigurationEvent event, + Emitter emit, + ) { + if (state is ChartsLoaded) { + final currentState = state as ChartsLoaded; + + _realTimeTimer?.cancel(); + + if (event.configuration.realTime) { + _realTimeTimer = Timer.periodic( + event.configuration.pointsDistance, + (_) => add(FetchRealTimeDataEvent()), + ); + } + + emit(currentState.copyWith(configuration: event.configuration)); + } + } + + + Future _onFetchChartsData( + FetchChartsDataEvent event, + Emitter emit, + ) async { + if (state is ChartsLoaded) { + final currentState = state as ChartsLoaded; + emit(ChartsFetchingData(currentState)); + + final topics = _getFetchTopics(currentState); + final now = DateTime.now(); + final params = GetChartsDataUseCaseParams( + period: currentState.configuration.timeRange, + start: currentState.configuration.customStartDate ?? now, + end: currentState.configuration.customEndDate ?? now, + interval: currentState.configuration.pointsDistance, + topics: topics, + ); + + final result = await getChartsDataUseCase(params); + + result.fold( + (failure) => emit(currentState.copyWith( + message: BottomMessage( + "Не удалось получить данные графиков", + isError: true, + ), + )), + (data) { + emit(currentState.copyWith( + chartData: data, + message: BottomMessage("Данные успешно получены"), + )); + }, + ); + } + } + + Future _onFetchRealTimeData( + FetchRealTimeDataEvent event, + Emitter emit, + ) async { + if (state is ChartsLoaded) { + final currentState = state as ChartsLoaded; + + final topics = _getFetchTopics(currentState); + final now = DateTime.now(); + final params = GetChartsDataUseCaseParams( + period: TimeRange.custom, + start: now.subtract(currentState.configuration.pointsDistance), + end: now, + interval: currentState.configuration.pointsDistance, + topics: topics, + ); + + final result = await getChartsDataUseCase(params); + + result.fold( + (failure) => emit(currentState.copyWith( + message: BottomMessage( + "Не удалось получить данные в реальном времени", + isError: true, + ), + )), + (newData) { + final updatedChartData = _updateRealTimeData(currentState.chartData, newData); + emit(currentState.copyWith(chartData: updatedChartData)); + }, + ); + } + } + + MiniChartDataModel? _updateRealTimeData( + MiniChartDataModel? currentData, + MiniChartDataModel newData, + ) { + if (currentData == null) return newData; + + Map>>> updatedData = {}; + + currentData.data.forEach((equipKey, equipValue) { + updatedData[equipKey] = {}; + + equipValue.forEach((paramKey, paramValue) { + updatedData[equipKey]![paramKey] = {}; + + paramValue.forEach((subParamKey, points) { + if (points != null) { + var newPoints = List.from(points); + + // Добавляем новую точку и удаляем первую + if (newData.data[equipKey]?[paramKey]?[subParamKey]?.isNotEmpty ?? false) { + newPoints.removeAt(0); + newPoints.add(newData.data[equipKey]![paramKey]![subParamKey]!.first); + } + + updatedData[equipKey]![paramKey]![subParamKey] = newPoints; + } + }); + }); + }); + + return MiniChartDataModel(updatedData); + } + + Map _getFetchTopics(ChartsLoaded state){ + Map topics = {}; + if(state.selectedEquipmentKeys != null && state.selectedParameterKeys != null) { + for (String equipKey in state.selectedEquipmentKeys!) { + EquipmentEntity equipment = state.equipmentList.equipment.firstWhere((e) => e.key==equipKey); + topics[equipKey] = {}; + for(String paramKey in state.selectedParameterKeys!){ + topics[equipKey][paramKey] = []; + if(equipment.parameters[paramKey] != null) { + for (var topic in equipment.parameters[paramKey]!.subparameters.entries) { + topics[equipKey][paramKey].add(topic.key); + } + } + } + } + } + return topics; + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/charts/presentation/bloc/charts_event.dart b/flutter_front/lib/features/charts/presentation/bloc/charts_event.dart new file mode 100644 index 0000000..fe1305f --- /dev/null +++ b/flutter_front/lib/features/charts/presentation/bloc/charts_event.dart @@ -0,0 +1,24 @@ +// charts_event.dart +part of 'charts_bloc.dart'; + +abstract class ChartsEvent {} + +class InitializeCharts extends ChartsEvent {} + +class SaveSelectedEquipmentEvent extends ChartsEvent { + final List? equipmentKeys; + SaveSelectedEquipmentEvent({required this.equipmentKeys}); +} + +class SaveSelectedParametersEvent extends ChartsEvent { + final List? parameterKeys; + SaveSelectedParametersEvent({required this.parameterKeys}); +} + +class UpdateChartConfigurationEvent extends ChartsEvent { + final ChartConfiguration configuration; + UpdateChartConfigurationEvent(this.configuration); +} + +class FetchChartsDataEvent extends ChartsEvent {} +class FetchRealTimeDataEvent extends ChartsEvent {} \ No newline at end of file diff --git a/flutter_front/lib/features/charts/presentation/bloc/charts_state.dart b/flutter_front/lib/features/charts/presentation/bloc/charts_state.dart new file mode 100644 index 0000000..f95b0c2 --- /dev/null +++ b/flutter_front/lib/features/charts/presentation/bloc/charts_state.dart @@ -0,0 +1,62 @@ +// charts_state.dart +part of 'charts_bloc.dart'; + +abstract class ChartsState { + final BottomMessage? message; + ChartsState({this.message}); +} + +class ChartsInitial extends ChartsState {} + +class ChartsLoading extends ChartsState {} + +class ChartsSavingEquipment extends ChartsState { + final ChartsLoaded previousState; + ChartsSavingEquipment(this.previousState); +} + +class ChartsFetchingData extends ChartsState { + final ChartsLoaded previousState; + ChartsFetchingData(this.previousState); +} + +class ChartsError extends ChartsState { + final String errorMessage; + ChartsError(this.errorMessage); +} + +class ChartsLoaded extends ChartsState { + final EquipmentListEntity equipmentList; + final List? selectedEquipmentKeys; + final List? selectedParameterKeys; + final ChartConfiguration configuration; + final MiniChartDataModel? chartData; // Add this + final BottomMessage? message; + + ChartsLoaded({ + required this.equipmentList, + this.selectedEquipmentKeys, + this.selectedParameterKeys, + required this.configuration, + this.chartData, // Add this + this.message, + }); + + // Update copyWith method accordingly + ChartsLoaded copyWith({ + Optional>? selectedEquipmentKeys, + Optional>? selectedParameterKeys, + ChartConfiguration? configuration, + MiniChartDataModel? chartData, // Add this + BottomMessage? message, + }) { + return ChartsLoaded( + equipmentList: equipmentList, + selectedEquipmentKeys: selectedEquipmentKeys?.value ?? this.selectedEquipmentKeys, + selectedParameterKeys: selectedParameterKeys?.value ?? this.selectedParameterKeys, + configuration: configuration ?? this.configuration, + chartData: chartData ?? this.chartData, // Add this + message: message, + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/charts/presentation/pages/charts_page.dart b/flutter_front/lib/features/charts/presentation/pages/charts_page.dart new file mode 100644 index 0000000..68089b1 --- /dev/null +++ b/flutter_front/lib/features/charts/presentation/pages/charts_page.dart @@ -0,0 +1,104 @@ +// charts_page.dart +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../locator_service.dart'; +import '../../../../shared/presentation/responsive_scaffold.dart'; +import '../../domain/entities/chart_config.dart'; +import '../bloc/charts_bloc.dart'; +import '../widgets/charts_grid.dart'; +import '../widgets/control_panel.dart'; + +class ChartsPage extends StatelessWidget { + const ChartsPage({super.key}); + + @override + Widget build(BuildContext context) { + return ResponsiveScaffold( + title: "Графики", + body: BlocProvider( + create: (_) => getIt()..add(InitializeCharts()), + child: BlocListener( + listenWhen: (previous, current) => current.message != null, + listener: (context, state) { + if (state.message != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 500), + content: Text(state.message!.message), + backgroundColor: + state.message!.isError ? Colors.red : Colors.green, + ), + ); + } + }, + child: BlocBuilder( + builder: (context, state) { + if (state is ChartsInitial || state is ChartsLoading) { + return const Center(child: CupertinoActivityIndicator()); + } else if (state is ChartsSavingEquipment) { + return Stack( + children: [ + _buildContent(context, state.previousState), + const Center(child: CupertinoActivityIndicator()), + ], + ); + } else if (state is ChartsFetchingData) { + return Stack( + children: [ + _buildContent(context, state.previousState), + const Center(child: CupertinoActivityIndicator()), + ], + ); + } else if (state is ChartsLoaded) { + return _buildContent(context, state); + } else if (state is ChartsError) { + return Center(child: Text(state.errorMessage)); + } + return Container(); + }, + ), + ), + ), + ); + } + + Widget _buildContent(BuildContext context, ChartsLoaded state) { + return Column( + children: [ + ChartsControlPanel( + equipmentList: state.equipmentList, + selectedEquipmentKeys: state.selectedEquipmentKeys, + selectedParameterKeys: state.selectedParameterKeys, + configuration: state.configuration, + onEquipmentChanged: (selectedKeys) { + context.read().add( + SaveSelectedEquipmentEvent(equipmentKeys: selectedKeys), + ); + }, + onParametersChanged: (selectedKeys) { + context.read().add( + SaveSelectedParametersEvent(parameterKeys: selectedKeys), + ); + }, + onConfigurationChanged: (config) { + context.read().add( + UpdateChartConfigurationEvent(config), + ); + }, + onFetchData: () { + context.read().add(FetchChartsDataEvent()); + }, + ), + Expanded( + child: state.chartData != null + ? ChartsGrid(chartData: state.chartData!) + : const Center( + child: Text("Нажмите 'Получить данные' для отображения графиков"), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/charts/presentation/widgets/aggregation_control.dart b/flutter_front/lib/features/charts/presentation/widgets/aggregation_control.dart new file mode 100644 index 0000000..43d7576 --- /dev/null +++ b/flutter_front/lib/features/charts/presentation/widgets/aggregation_control.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import '../../domain/entities/chart_config.dart'; + +class AggregationControl extends StatelessWidget { + final Set selectedAggregations; + final Function(Set) onAggregationChanged; + + const AggregationControl({ + super.key, + required this.selectedAggregations, + required this.onAggregationChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Функция агрегации', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(20), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: AggregationType.values.map((type) { + final isSelected = selectedAggregations.contains(type); + return Padding( + padding: const EdgeInsets.all(4), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () { + final newSelection = {type}; // Создаем новое множество только с выбранным элементом + onAggregationChanged(newSelection); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: isSelected + ? theme.primaryColor + : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _getAggregationLabel(type), + style: TextStyle( + color: isSelected ? Colors.white : Colors.black87, + fontWeight: isSelected + ? FontWeight.w500 + : FontWeight.normal, + ), + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ); + } + + String _getAggregationLabel(AggregationType type) { + switch (type) { + case AggregationType.min: + return 'Минимум'; + case AggregationType.max: + return 'Максимум'; + case AggregationType.mean: + return 'Среднее'; + } + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/charts/presentation/widgets/anomaly_warnings_control.dart b/flutter_front/lib/features/charts/presentation/widgets/anomaly_warnings_control.dart new file mode 100644 index 0000000..fc38dca --- /dev/null +++ b/flutter_front/lib/features/charts/presentation/widgets/anomaly_warnings_control.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class AnomalyWarningsControl extends StatelessWidget { + final bool showWarnings; + final bool showAnomalies; + final Function(bool) onWarningsChanged; + final Function(bool) onAnomaliesChanged; + + const AnomalyWarningsControl({ + super.key, + required this.showWarnings, + required this.showAnomalies, + required this.onWarningsChanged, + required this.onAnomaliesChanged, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + direction: Axis.vertical, + children: [ + SizedBox( + width: 180, + child: CheckboxListTile( + dense: true, + title: const Text('Превышения'), + value: showWarnings, + onChanged: (value) => onWarningsChanged(value ?? false), + controlAffinity: ListTileControlAffinity.leading, + ), + ), + const SizedBox(height: 8), + SizedBox( + width: 160, + child: CheckboxListTile( + dense: true, + title: const Text('Аномалии'), + value: showAnomalies, + onChanged: (value) => onAnomaliesChanged(value ?? false), + controlAffinity: ListTileControlAffinity.leading, + ), + ), + ], + ); + } +} diff --git a/flutter_front/lib/features/charts/presentation/widgets/charts_grid.dart b/flutter_front/lib/features/charts/presentation/widgets/charts_grid.dart new file mode 100644 index 0000000..d43cbef --- /dev/null +++ b/flutter_front/lib/features/charts/presentation/widgets/charts_grid.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:reorderables/reorderables.dart'; +import '../../../../shared/data/models/minichart_data_model.dart'; + +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../../../../shared/data/models/minichart_data_model.dart'; +import '../../../../shared/presentation/resizeable_card.dart'; + +class ChartsGrid extends StatefulWidget { + final MiniChartDataModel chartData; + + const ChartsGrid({ + super.key, + required this.chartData, + }); + + @override + State createState() => _ChartsGridState(); +} + +class _ChartsGridState extends State { + List _getThemeColors(BuildContext context) { + final brightness = Theme.of(context).brightness; + + if (brightness == Brightness.dark) { + return [ + const Color(0xFF2196F3), + const Color(0xFF4CAF50), + const Color(0xFFE91E63), + const Color(0xFFFFA726), + const Color(0xFF9C27B0), + const Color(0xFF00BCD4), + const Color(0xFFFF5722), + const Color(0xFF3F51B5), + ]; + } + + return [ + const Color(0xFF1E88E5), + const Color(0xFF43A047), + const Color(0xFFD81B60), + const Color(0xFFFC8E34), + const Color(0xFF8E24AA), + const Color(0xFF00ACC1), + const Color(0xFFFF7043), + const Color(0xFF5E35B1), + ]; + } + + final Map _cardSizes = {}; + + @override + Widget build(BuildContext context) { + Map?>>> parameterGroups = {}; + final size = MediaQuery.of(context).size; + final initialWidth = size.width / 2 - 100; + final initialHeight = size.height / 2 - 100; + + widget.chartData.data.forEach((equipKey, equipValue) { + equipValue.forEach((paramKey, paramValue) { + parameterGroups[paramKey] ??= {}; + parameterGroups[paramKey]![equipKey] = paramValue; + }); + }); + + return LayoutBuilder( + builder: (context, constraints) { + return ReorderableWrap( + spacing: 8, + runSpacing: 8, + padding: const EdgeInsets.all(8), + minMainAxisCount: 1, // Минимум 1 элемент в строке + maxMainAxisCount: (constraints.maxWidth / 200).floor(), // Максимальное количество элементов в строке + onReorder: (oldIndex, newIndex) { + setState(() { + // Реализация перемещения, если необходимо + }); + }, + children: List.generate( + parameterGroups.length, + (index) { + String paramKey = parameterGroups.keys.elementAt(index); + _cardSizes[paramKey] ??= Size(initialWidth, initialHeight); + + return ResizeableCard( + key: ValueKey(paramKey), + initialWidth: _cardSizes[paramKey]!.width, + initialHeight: _cardSizes[paramKey]!.height, + onSizeChanged: (newSize) { + setState(() { + _cardSizes[paramKey] = newSize; + }); + }, + maxWidth: constraints.maxWidth - 16, // Максимальная ширина с учетом отступов + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + paramKey, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + Expanded( + child: LineChart( + _createChartData(context, parameterGroups[paramKey]!), + ), + ), + ], + ), + ); + }, + ), + ); + } + ); + } + + LineChartData _createChartData(BuildContext context, Map?>> parameterData) { + List lines = []; + int equipmentIndex = 0; + final baseColors = _getThemeColors(context); + + parameterData.forEach((equipKey, subParams) { + Color baseColor = baseColors[equipmentIndex % baseColors.length]; + int subParamIndex = 0; + int totalSubParams = subParams.length; + + subParams.forEach((subParamKey, dataPoints) { + if (dataPoints != null && dataPoints.isNotEmpty) { + Color lineColor; + if (totalSubParams > 1) { + final hslColor = HSLColor.fromColor(baseColor); + double saturation = (hslColor.saturation - 0.3 * subParamIndex).clamp(0.3, 1.0); + double lightness = (hslColor.lightness + 0.1 * subParamIndex).clamp(0.3, 0.7); + + lineColor = hslColor + .withSaturation(saturation) + .withLightness(lightness) + .toColor(); + } else { + lineColor = baseColor; + } + + lines.add( + LineChartBarData( + spots: dataPoints.asMap().entries.map((entry) { + return FlSpot( + entry.key.toDouble(), + entry.value.value, + ); + }).toList(), + color: lineColor, + dotData: const FlDotData(show: false), + isCurved: true, + barWidth: 2, + ), + ); + subParamIndex++; + } + }); + equipmentIndex++; + }); + + return LineChartData( + gridData: const FlGridData(show: true), + titlesData: const FlTitlesData( + rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + borderData: FlBorderData(show: true), + lineBarsData: lines, + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/charts/presentation/widgets/control_panel.dart b/flutter_front/lib/features/charts/presentation/widgets/control_panel.dart new file mode 100644 index 0000000..da3feb1 --- /dev/null +++ b/flutter_front/lib/features/charts/presentation/widgets/control_panel.dart @@ -0,0 +1,140 @@ +import 'package:clean_architecture/features/charts/presentation/widgets/parametr_selector.dart'; +import 'package:clean_architecture/features/charts/presentation/widgets/time_range_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../shared/domain/entities/equipment/equipment_list_entity.dart'; +import '../../../../shared/presentation/equipment_selector/multiple_equipment_selector.dart'; +import '../../domain/entities/chart_config.dart'; +import '../bloc/charts_bloc.dart'; +import 'aggregation_control.dart'; +import 'anomaly_warnings_control.dart'; + +class ChartsControlPanel extends StatelessWidget { + final EquipmentListEntity equipmentList; + final List? selectedEquipmentKeys; + final List? selectedParameterKeys; + final ChartConfiguration configuration; + final Function(List?) onEquipmentChanged; + final Function(List?) onParametersChanged; + final Function(ChartConfiguration) onConfigurationChanged; + final VoidCallback onFetchData; + + const ChartsControlPanel({ + super.key, + required this.equipmentList, + required this.selectedEquipmentKeys, + required this.selectedParameterKeys, + required this.configuration, + required this.onEquipmentChanged, + required this.onParametersChanged, + required this.onConfigurationChanged, + required this.onFetchData, + }); + + @override + Widget build(BuildContext context) { + return Builder( + builder: (context) { + final commonParameters = equipmentList.getCommonParameters( + selectedEquipmentKeys ?? []); + + return Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + alignment: WrapAlignment.center, + spacing: 16, + children: [ + SizedBox( + width: 200, + child: MultipleEquipmentSelector( + equipmentList: equipmentList, + selectedEquipmentKeys: selectedEquipmentKeys, + onChanged: onEquipmentChanged, + ), + ), + SizedBox( + width: 200, + child: ParameterSelector( + parameters: commonParameters, + selectedParameterKeys: selectedParameterKeys, + onChanged: onParametersChanged, + isEnabled: selectedEquipmentKeys?.isNotEmpty ?? false, + ), + ), + AnomalyWarningsControl( + showWarnings: configuration.showWarnings, + showAnomalies: configuration.showAnomalies, + onWarningsChanged: (value) { + onConfigurationChanged( + configuration.copyWith(showWarnings: value), + ); + }, + onAnomaliesChanged: (value) { + onConfigurationChanged( + configuration.copyWith(showAnomalies: value), + ); + }, + ), + AggregationControl( + selectedAggregations: configuration.selectedAggregations, + onAggregationChanged: (value) { + onConfigurationChanged( + configuration.copyWith(selectedAggregations: value), + ); + }, + ), + TimeRangeSelector( + timeRange: configuration.timeRange, + customStartDate: configuration.customStartDate, + customEndDate: configuration.customEndDate, + realTime: configuration.realTime, + pointsDistance: configuration.pointsDistance, + onTimeRangeChanged: (timeRange) { + onConfigurationChanged( + configuration.copyWith( + timeRange: timeRange, + realTime: false, + ), + ); + }, + onCustomRangeChanged: (start, end) { + onConfigurationChanged( + configuration.copyWith( + customStartDate: start, + customEndDate: end, + ), + ); + }, + onRealTimeChanged: (value) { + onConfigurationChanged( + configuration.copyWith(realTime: value), + ); + }, + onPointsDistanceChanged: (distance) { + onConfigurationChanged( + configuration.copyWith(pointsDistance: distance), + ); + }, + ), + ElevatedButton( + onPressed: selectedEquipmentKeys?.isNotEmpty == true && + selectedParameterKeys?.isNotEmpty == true + ? () { + context.read().add(FetchChartsDataEvent()); + onFetchData(); + } + : null, + child: const Text('Получить данные'), + ), + ], + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/charts/presentation/widgets/parametr_selector.dart b/flutter_front/lib/features/charts/presentation/widgets/parametr_selector.dart new file mode 100644 index 0000000..1f257a8 --- /dev/null +++ b/flutter_front/lib/features/charts/presentation/widgets/parametr_selector.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import '../../../../../shared/domain/entities/equipment/equipment_parametr_entity.dart'; + +class ParameterSelector extends StatefulWidget { + final Map parameters; + final List? selectedParameterKeys; + final Function(List?) onChanged; + final bool isEnabled; + + const ParameterSelector({ + super.key, + required this.parameters, + required this.selectedParameterKeys, + required this.onChanged, + this.isEnabled = true, + }); + + @override + State createState() => _ParameterSelectorState(); +} + +class _ParameterSelectorState extends State { + List currentSelection = []; + + @override + void initState() { + super.initState(); + currentSelection = List.from(widget.selectedParameterKeys ?? []); + } + + @override + void didUpdateWidget(ParameterSelector oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedParameterKeys != widget.selectedParameterKeys) { + currentSelection = List.from(widget.selectedParameterKeys ?? []); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Параметры', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + MenuAnchor( + builder: (context, controller, child) { + return InkWell( + onTap: widget.isEnabled + ? () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + } + : null, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.grey), + ), + ), + child: Row( + children: [ + Text( + _getDisplayText(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: widget.isEnabled + ? null + : Theme.of(context).disabledColor, + ), + ), + Icon( + Icons.arrow_drop_down, + color: + widget.isEnabled ? null : Theme.of(context).disabledColor, + ), + ], + ), + ), + ); + }, + menuChildren: widget.isEnabled + ? [ + Container( + constraints: const BoxConstraints(maxHeight: 300), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: widget.parameters.entries.map((entry) { + return CheckboxListTile( + title: Text( + '${entry.value.translate} (${entry.value.unit})'), + value: currentSelection.contains(entry.key), + onChanged: (bool? checked) { + setState(() { + if (checked == true) { + currentSelection.add(entry.key); + } else { + currentSelection.remove(entry.key); + } + }); + widget.onChanged( + currentSelection.isEmpty ? null : currentSelection); + }, + ); + }).toList(), + ), + ), + ), + ] + : [], + ), + ], + ); + } + + String _getDisplayText() { + if (!widget.isEnabled) { + return 'Выберите оборудование'; + } + if (currentSelection.isEmpty) { + return 'Выберите параметры'; + } + return 'Выбрано: ${currentSelection.length}'; + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/charts/presentation/widgets/time_range_selector.dart b/flutter_front/lib/features/charts/presentation/widgets/time_range_selector.dart new file mode 100644 index 0000000..a0759c9 --- /dev/null +++ b/flutter_front/lib/features/charts/presentation/widgets/time_range_selector.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import '../../domain/entities/chart_config.dart'; + +class TimeRangeSelector extends StatelessWidget { + final TimeRange timeRange; + final DateTime customStartDate; + final DateTime customEndDate; + final bool realTime; + final Duration pointsDistance; + final Function(TimeRange) onTimeRangeChanged; + final Function(DateTime?, DateTime?) onCustomRangeChanged; + final Function(bool) onRealTimeChanged; + final Function(Duration) onPointsDistanceChanged; + + const TimeRangeSelector({ + super.key, + required this.timeRange, + required this.customStartDate, + required this.customEndDate, + required this.realTime, + required this.pointsDistance, + required this.onTimeRangeChanged, + required this.onCustomRangeChanged, + required this.onRealTimeChanged, + required this.onPointsDistanceChanged, + }); + + @override + Widget build(BuildContext context) { + final availableIntervals = _calculateAvailableIntervals(); + final currentPointsDistance = availableIntervals.contains(pointsDistance) + ? pointsDistance + : availableIntervals.first; + + if (currentPointsDistance != pointsDistance) { + // Если текущее значение недоступно, устанавливаем первое доступное + WidgetsBinding.instance.addPostFrameCallback((_) { + onPointsDistanceChanged(currentPointsDistance); + }); + } + + return Wrap( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Временной диапазон', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + SizedBox( + width: 200, + child: DropdownButton( + value: timeRange, + isExpanded: true, + onChanged: (TimeRange? newValue) { + if (newValue != null) { + onTimeRangeChanged(newValue); + } + }, + items: const [ + DropdownMenuItem(value: TimeRange.day, child: Text('1 день')), + DropdownMenuItem(value: TimeRange.halfDay, child: Text('12 часов')), + DropdownMenuItem(value: TimeRange.hour, child: Text('1 час')), + DropdownMenuItem(value: TimeRange.fifteenMinutes, child: Text('15 минут')), + DropdownMenuItem(value: TimeRange.custom, child: Text('Свой период')), + ], + ), + ), + ], + ), + const SizedBox(width: 16), + if (timeRange == TimeRange.custom) + Padding( + padding: const EdgeInsets.only(left: 16), + child: _buildCustomRangePicker(context), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Интервал между точками', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + SizedBox( + width: 200, + child: DropdownButton( + value: currentPointsDistance, + isExpanded: true, + onChanged: (Duration? newValue) { + if (newValue != null) { + onPointsDistanceChanged(newValue); + } + }, + items: availableIntervals + .map((interval) => DropdownMenuItem( + value: interval, + child: Text(_formatDuration(interval)), + )) + .toList(), + ), + ), + ], + ), + if (timeRange != TimeRange.custom) + Padding( + padding: const EdgeInsets.only(left: 16), + child: Column( + children: [ + const Text('Real time'), + const SizedBox(height: 8), + Switch( + value: realTime, + onChanged: onRealTimeChanged, + ), + ], + ), + ), + ], + ); + } + + Widget _buildCustomRangePicker(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton.icon( + icon: const Icon(Icons.calendar_today), + label: Text(customStartDate.toIso8601String()), + onPressed: () => _showDatePicker(context, true), + ), + const SizedBox(width: 16), + TextButton.icon( + icon: const Icon(Icons.calendar_today), + label: Text(customEndDate.toIso8601String()), + onPressed: () => _showDatePicker(context, false), + ), + ], + ); + } + + List _calculateAvailableIntervals() { + final totalDuration = _calculateTotalDuration(); + const maxPoints = 2000; + + final minInterval = Duration( + milliseconds: totalDuration.inMilliseconds ~/ maxPoints, + ); + + final intervals = { + const Duration(seconds: 1), + const Duration(seconds: 5), + const Duration(seconds: 15), + const Duration(seconds: 30), + const Duration(minutes: 1), + const Duration(minutes: 5), + const Duration(minutes: 15), + const Duration(minutes: 30), + const Duration(hours: 1), + const Duration(days: 1), + const Duration(days: 7), + }; + + return intervals + .where((interval) => (interval >= minInterval && interval <= totalDuration)) + .toList() + ..sort(); + } + + Duration _calculateTotalDuration() { + switch (timeRange) { + case TimeRange.day: + return const Duration(days: 1); + case TimeRange.halfDay: + return const Duration(hours: 12); + case TimeRange.hour: + return const Duration(hours: 1); + case TimeRange.fifteenMinutes: + return const Duration(minutes: 15); + case TimeRange.custom: + return customEndDate.difference(customStartDate); + } + } + + String _formatDuration(Duration duration) { + if(duration.inDays == 7){ + return "1 нед"; + } + if (duration.inHours > 0) { + return '${duration.inHours} ч'; + } + if (duration.inMinutes > 0) { + return '${duration.inMinutes} мин'; + } + return '${duration.inSeconds} сек'; + } + + Future _showDatePicker(BuildContext context, bool isStart) async { + final initialDate = isStart ? customStartDate : customEndDate; + final DateTime? picked = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + + if (picked != null) { + final TimeOfDay? time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(initialDate), + ); + + if (time != null) { + final DateTime selectedDateTime = DateTime( + picked.year, + picked.month, + picked.day, + time.hour, + time.minute, + ); + + if (isStart) { + onCustomRangeChanged(selectedDateTime, customEndDate); + } else { + onCustomRangeChanged(customStartDate, selectedDateTime); + } + } + } + } +} \ No newline at end of file