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 diff --git a/flutter_front/lib/features/settings/data/datasorces/settings_remote_data_source.dart b/flutter_front/lib/features/settings/data/datasorces/settings_remote_data_source.dart new file mode 100644 index 0000000..5933fd6 --- /dev/null +++ b/flutter_front/lib/features/settings/data/datasorces/settings_remote_data_source.dart @@ -0,0 +1,30 @@ +import '../../../../core/http/api_client.dart'; +import '../../../../shared/data/datasources/remote_datasourse.dart'; + +abstract class SettingsRemoteDataSource extends RemoteDataSource{ + SettingsRemoteDataSource({required super.client}): super(basePath: '/settings'); + Future exportSettings(Map body); + Future> importSettings(); +} + +class SettingsRemoteDataSourceImpl extends SettingsRemoteDataSource { + SettingsRemoteDataSourceImpl({required super.client}); + + @override + Future exportSettings(Map body) async { + await makeRequest( + path: 'export_settings', + method: RequestMethod.POST, + body: body + ); + return; + } + + @override + Future> importSettings() async { + return await makeRequest( + path: 'import_settings', + method: RequestMethod.GET, + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/settings/data/models/settings_model.dart b/flutter_front/lib/features/settings/data/models/settings_model.dart new file mode 100644 index 0000000..28495d9 --- /dev/null +++ b/flutter_front/lib/features/settings/data/models/settings_model.dart @@ -0,0 +1,49 @@ +import 'package:clean_architecture/features/home/domain/entities/fetch_topics.dart'; +import 'package:flutter/material.dart'; + +import '../../domain/entities/settings_entity.dart'; + +class SettingsModel { + final String? theme; + final Map? miniChartsTopic; + final List? collapsedEquipment; + + SettingsModel({ + this.theme, + this.miniChartsTopic, + this.collapsedEquipment, + }); + + factory SettingsModel.fromJson(Map json) { + return SettingsModel( + theme: json['theme'], + miniChartsTopic: json['mini_charts_topic'], + collapsedEquipment: (json['collapsed_equipment'] as List).cast(), + ); + } + + + Map toJson() { + return { + 'theme': theme, + 'mini_charts_topic': miniChartsTopic, + 'collapsed_equipment': collapsedEquipment + }; + } + + SettingsEntity toEntity() { + return SettingsEntity( + theme: theme == "light" ? ThemeMode.light : (theme == "dark" ? ThemeMode.dark : null), + miniChartsTopics: miniChartsTopic != null ? FetchMiniChartsTopics.fromMap(miniChartsTopic!) : null, + collapsedEquipment: collapsedEquipment + ); + } + + factory SettingsModel.fromEntity(SettingsEntity entity) { + return SettingsModel( + theme: entity.theme == ThemeMode.light ? "light" : (entity.theme == ThemeMode.dark ? "dark" : null), + miniChartsTopic: entity.miniChartsTopics?.toMap(), + collapsedEquipment: entity.collapsedEquipment + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/settings/data/repositories/settings_repository_impl.dart b/flutter_front/lib/features/settings/data/repositories/settings_repository_impl.dart new file mode 100644 index 0000000..bf6c64c --- /dev/null +++ b/flutter_front/lib/features/settings/data/repositories/settings_repository_impl.dart @@ -0,0 +1,60 @@ +import 'package:clean_architecture/features/settings/data/datasorces/settings_remote_data_source.dart'; +import 'package:clean_architecture/features/settings/domain/entities/settings_entity.dart'; +import 'package:clean_architecture/features/settings/domain/repositories/settings_repository.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter/material.dart'; + +import '../../../../core/error/failure.dart'; +import '../../../../shared/data/datasources/local/hive_datasource.dart'; +import '../../../../shared/data/repositories/base_repository.dart'; +import '../models/settings_model.dart'; + +class SettingsRepositoryImpl extends BaseRepository implements SettingsRepository { + final HiveLocalDataSource localDataSource; + final SettingsRemoteDataSource remoteDataSource; + final Failure failure = CacheFailure(); + + SettingsRepositoryImpl( + {required this.localDataSource, required this.remoteDataSource}); + + @override + Future getTheme() async { + final res = await performNullableOperation(() { + return localDataSource.getTheme(); + }, failure); + return res.fold( + (f) => ThemeMode.light, + (theme) { + if(theme == "dark") { + return ThemeMode.dark; + } else { + return ThemeMode.light; + } + } + ); + } + + @override + Future> setTheme(ThemeMode theme) async { + if (theme == ThemeMode.dark){ + await performNullableOperation(() => localDataSource.saveTheme("dark"), failure); + } else { + await performNullableOperation(() => localDataSource.saveTheme("light"), failure); + } + return const Right(null); + } + + @override + Future> exportSettings(SettingsEntity settings) async => + performOperation(() => remoteDataSource.exportSettings( + SettingsModel.fromEntity(settings).toJson()), failure); + + @override + Future> importSettings() async { + final settingsRes = await performNonNullOperation(() => remoteDataSource.importSettings(), failure); + return settingsRes.fold( + (f) => Left(ServerFailure()), + (settings) => Right(SettingsModel.fromJson(settings).toEntity()) + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/settings/domain/entities/settings_entity.dart b/flutter_front/lib/features/settings/domain/entities/settings_entity.dart new file mode 100644 index 0000000..b26a6c5 --- /dev/null +++ b/flutter_front/lib/features/settings/domain/entities/settings_entity.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +import '../../../home/domain/entities/fetch_topics.dart'; + +class SettingsEntity { + final ThemeMode? theme; + final FetchMiniChartsTopics? miniChartsTopics; + final List? collapsedEquipment; + + SettingsEntity({ + this.theme, + this.miniChartsTopics, + this.collapsedEquipment + }); +} \ No newline at end of file diff --git a/flutter_front/lib/features/settings/domain/repositories/settings_repository.dart b/flutter_front/lib/features/settings/domain/repositories/settings_repository.dart new file mode 100644 index 0000000..9eb1f30 --- /dev/null +++ b/flutter_front/lib/features/settings/domain/repositories/settings_repository.dart @@ -0,0 +1,13 @@ +import 'package:clean_architecture/features/settings/domain/entities/settings_entity.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter/material.dart'; + +import '../../../../core/error/failure.dart'; +import '../../data/models/settings_model.dart'; + +abstract class SettingsRepository { + Future getTheme(); + Future> setTheme(ThemeMode theme); + Future> exportSettings(SettingsEntity settings); + Future> importSettings(); +} \ No newline at end of file diff --git a/flutter_front/lib/features/settings/domain/usecases/export_settings_usecase.dart b/flutter_front/lib/features/settings/domain/usecases/export_settings_usecase.dart new file mode 100644 index 0000000..a772f94 --- /dev/null +++ b/flutter_front/lib/features/settings/domain/usecases/export_settings_usecase.dart @@ -0,0 +1,35 @@ +import 'package:clean_architecture/core/error/failure.dart'; +import 'package:clean_architecture/features/settings/domain/entities/settings_entity.dart'; +import 'package:clean_architecture/shared/domain/usecases/no_params.dart'; +import 'package:dartz/dartz.dart'; + +import '../../../../core/usecases/usecase.dart'; +import '../../../../shared/domain/repositories/hive_repository.dart'; +import '../repositories/settings_repository.dart'; + +class ExportSettingsUseCase implements UseCase{ + final SettingsRepository settingsRepository; + final HiveRepository hiveRepository; + ExportSettingsUseCase(this.settingsRepository, this.hiveRepository); + + @override + Future> call(NoParams params) async { + final theme = await settingsRepository.getTheme(); + final miniChartsRes = await hiveRepository.getMiniChartsTopics(); + final collapsedRes = await hiveRepository.getCollapsedEquipment(); + return miniChartsRes.fold((f) => Left(f), + (miniCharts) async { + return collapsedRes.fold( + (f) => Left(f), + (collapsed) async { + final settings = SettingsEntity( + theme: theme, + miniChartsTopics: miniCharts, + collapsedEquipment: collapsed + ); + return await settingsRepository.exportSettings(settings); + } + ); + }); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/settings/domain/usecases/import_settings_usecase.dart b/flutter_front/lib/features/settings/domain/usecases/import_settings_usecase.dart new file mode 100644 index 0000000..05bb6de --- /dev/null +++ b/flutter_front/lib/features/settings/domain/usecases/import_settings_usecase.dart @@ -0,0 +1,29 @@ +import 'package:clean_architecture/core/error/failure.dart'; +import 'package:clean_architecture/features/settings/domain/entities/settings_entity.dart'; +import 'package:clean_architecture/features/settings/domain/repositories/settings_repository.dart'; +import 'package:clean_architecture/shared/domain/repositories/hive_repository.dart'; +import 'package:clean_architecture/shared/domain/usecases/no_params.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter/material.dart'; + +import '../../../../core/usecases/usecase.dart'; + +class ImportSettingsUseCase implements UseCase{ + final SettingsRepository settingsRepository; + final HiveRepository hiveRepository; + + ImportSettingsUseCase(this.settingsRepository, this.hiveRepository); + @override + Future> call(NoParams params) async { + final settingsRes = await settingsRepository.importSettings(); + return settingsRes.fold( + (f) => Left(f), + (settings) async { + if(settings.miniChartsTopics != null) await hiveRepository.saveMiniChartsTopics(settings.miniChartsTopics!); + if(settings.theme != null) await settingsRepository.setTheme(settings.theme!); + if(settings.collapsedEquipment != null) await hiveRepository.saveCollapsedEquipment(settings.collapsedEquipment!); + return Right(settings.theme); + } + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/settings/presentation/bloc/settings_bloc.dart b/flutter_front/lib/features/settings/presentation/bloc/settings_bloc.dart new file mode 100644 index 0000000..9d3dd6f --- /dev/null +++ b/flutter_front/lib/features/settings/presentation/bloc/settings_bloc.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../shared/domain/usecases/no_params.dart'; +import '../../domain/entities/settings_entity.dart'; +import '../../domain/usecases/export_settings_usecase.dart'; +import '../../domain/usecases/import_settings_usecase.dart'; +import '../widgets/settings_message.dart'; + +part 'settings_event.dart'; + +part 'settings_state.dart'; + +// settings_bloc.dart +class SettingsBloc extends Bloc { + final ExportSettingsUseCase exportSettings; + final ImportSettingsUseCase importSettings; + + SettingsBloc({ + required this.exportSettings, + required this.importSettings, + }) : super(SettingsLoaded()) { + on(_onExportSettings); + on(_onImportSettings); + } + + Future _onExportSettings( + ExportSettingsEvent event, + Emitter emit, + ) async { + emit(SettingsProcessing()); + final settingsRes = await exportSettings(NoParams()); + settingsRes.fold( + (f) => emit(SettingsLoaded( + message: BottomMessage("Не удалось экспортировать настройки", + isError: true), + )), + (settings) => emit(SettingsLoaded( + message: BottomMessage("Настройки успешно сохранены на сервере"), + )), + ); + } + + Future _onImportSettings( + ImportSettingsEvent event, + Emitter emit, + ) async { + emit(SettingsProcessing()); + final settingsRes = await importSettings(NoParams()); + settingsRes.fold( + (f) => emit(SettingsLoaded( + message: BottomMessage("Не удалось импортировать настройки", + isError: true), + )), + (theme) => emit(SettingsLoaded( + message: + BottomMessage("Настройки успешно импортированы и применены"), + theme: theme)), + ); + } +} diff --git a/flutter_front/lib/features/settings/presentation/bloc/settings_event.dart b/flutter_front/lib/features/settings/presentation/bloc/settings_event.dart new file mode 100644 index 0000000..59c96f0 --- /dev/null +++ b/flutter_front/lib/features/settings/presentation/bloc/settings_event.dart @@ -0,0 +1,7 @@ +part of 'settings_bloc.dart'; + +abstract class SettingsEvent {} + +class ExportSettingsEvent extends SettingsEvent {} + +class ImportSettingsEvent extends SettingsEvent {} \ No newline at end of file diff --git a/flutter_front/lib/features/settings/presentation/bloc/settings_state.dart b/flutter_front/lib/features/settings/presentation/bloc/settings_state.dart new file mode 100644 index 0000000..87f9451 --- /dev/null +++ b/flutter_front/lib/features/settings/presentation/bloc/settings_state.dart @@ -0,0 +1,19 @@ +part of 'settings_bloc.dart'; + +abstract class SettingsState { + final BottomMessage? message; + const SettingsState({this.message}); +} + +class SettingsProcessing extends SettingsState{} + +class SettingsLoaded extends SettingsState { + final ThemeMode? theme; + SettingsLoaded({super.message, this.theme}); +} + +class SettingsError extends SettingsState { + final String errorMessage; + + SettingsError(this.errorMessage) : super(message: BottomMessage(errorMessage, isError: true)); +} \ No newline at end of file diff --git a/flutter_front/lib/features/settings/presentation/page/settings_page.dart b/flutter_front/lib/features/settings/presentation/page/settings_page.dart new file mode 100644 index 0000000..e1430f9 --- /dev/null +++ b/flutter_front/lib/features/settings/presentation/page/settings_page.dart @@ -0,0 +1,87 @@ +import 'package:clean_architecture/features/settings/presentation/widgets/import_export_section.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../core/theme/theme_service.dart'; +import '../../../../locator_service.dart'; +import '../../../../shared/presentation/responsive_scaffold.dart'; +import '../bloc/settings_bloc.dart'; +import '../widgets/settings_item.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => getIt(), + child: const SettingsView(), + ); + } +} + +class SettingsView extends StatelessWidget { + const SettingsView({super.key}); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + 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, + ), + ); + } + if (state is SettingsLoaded && state.theme != null) { + context.read().toggleTheme(theme: state.theme!); + } + if (state is SettingsProcessing){ + const Center(child: CupertinoActivityIndicator()); + } + }, + builder: (context, state) { + return ResponsiveScaffold( + title: 'Настройки', + body: _buildBody(context, state), + ); + }, + ); + } + + Widget _buildBody(BuildContext context, SettingsState state) { + return switch (state) { + SettingsLoaded() => _buildSettings(context, state), + _ => const SizedBox(), + }; + } + + Widget _buildSettings(BuildContext context, SettingsLoaded state) { + return ListView( + children: [ + ImportExport( + title: "Импорт/экспорт настроек", + children: [ + SettingsListTile( + title: 'Export Settings', + icon: Icons.upload, + onTap: () { + context.read().add(ExportSettingsEvent()); + }, + ), + SettingsListTile( + title: 'Import Settings', + icon: Icons.download, + onTap: () { + context.read().add(ImportSettingsEvent()); + }, + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/settings/presentation/widgets/import_export_section.dart b/flutter_front/lib/features/settings/presentation/widgets/import_export_section.dart new file mode 100644 index 0000000..f92d4bf --- /dev/null +++ b/flutter_front/lib/features/settings/presentation/widgets/import_export_section.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class ImportExport extends StatelessWidget { + final String title; + final List children; + + const ImportExport({ + super.key, + required this.title, + required this.children, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(10.0), + child: Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ...children, + ], + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/settings/presentation/widgets/settings_item.dart b/flutter_front/lib/features/settings/presentation/widgets/settings_item.dart new file mode 100644 index 0000000..ea6d4fb --- /dev/null +++ b/flutter_front/lib/features/settings/presentation/widgets/settings_item.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class SettingsListTile extends StatelessWidget { + final String title; + final IconData icon; + final VoidCallback onTap; + + const SettingsListTile({ + super.key, + required this.title, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(title), + leading: Icon(icon), + onTap: onTap, + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/settings/presentation/widgets/settings_message.dart b/flutter_front/lib/features/settings/presentation/widgets/settings_message.dart new file mode 100644 index 0000000..1137f39 --- /dev/null +++ b/flutter_front/lib/features/settings/presentation/widgets/settings_message.dart @@ -0,0 +1,6 @@ +class BottomMessage { + final String message; + final bool isError; + + BottomMessage(this.message, {this.isError = false}); +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/data/data_source/statistics_datasource.dart b/flutter_front/lib/features/statistics/data/data_source/statistics_datasource.dart new file mode 100644 index 0000000..e6452f4 --- /dev/null +++ b/flutter_front/lib/features/statistics/data/data_source/statistics_datasource.dart @@ -0,0 +1,41 @@ +import 'package:clean_architecture/features/statistics/domain/entities/warning_statistics_entity.dart'; +import 'package:clean_architecture/features/warnings/data/models/warning_model.dart'; +import 'package:clean_architecture/features/warnings/data/models/warnings_response_model.dart'; +import 'package:clean_architecture/shared/data/datasources/remote_datasourse.dart'; + +import '../../../../core/http/api_client.dart'; +import '../models/statistics_model.dart'; +import '../models/warning_statistics.dart'; + +abstract class StatisticsDatasource extends RemoteDataSource { + StatisticsDatasource({required super.client}) : super(basePath: '/statistics'); + + Future> getWarningStatistics(Map params); + Future> getEquipmentWarningsStatistics(Map params); +} + +class StatisticsDatasourceImpl extends StatisticsDatasource { + StatisticsDatasourceImpl({required super.client}); + + @override + Future> getWarningStatistics(Map params) async { + final res = await makeRequest( + path: 'warning_statistics', + method: RequestMethod.GET, + params: params, + ); + return StatisticsModel.fromJsonList(res); + } + + @override + Future> getEquipmentWarningsStatistics(Map params) async { + print(params); + final res = await makeRequest( + path: 'equipment_warning_statistics', + method: RequestMethod.GET, + params: params, + ); + print(res); + return res; + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/data/models/statistics_model.dart b/flutter_front/lib/features/statistics/data/models/statistics_model.dart new file mode 100644 index 0000000..07a4a45 --- /dev/null +++ b/flutter_front/lib/features/statistics/data/models/statistics_model.dart @@ -0,0 +1,83 @@ +import '../../domain/entities/statistics.dart'; + +class StatisticsModel { + final dynamic x; + final double y; + + const StatisticsModel({ + required this.x, + required this.y, + }); + + factory StatisticsModel.fromJson(Map json) { + return StatisticsModel( + x: json['x'], + y: json['y'].toDouble(), + ); + } + + factory StatisticsModel.fromEntity(Statistics entity) { + return StatisticsModel( + x: entity.x, + y: entity.y, + ); + } + + Statistics toEntity() { + return Statistics( + x: x, + y: y, + ); + } + + Map toJson() { + return { + 'x': x, + 'y': y, + }; + } + + static List fromJsonList(List jsonList) { + return jsonList.map((json) => StatisticsModel.fromJson(json)).toList(); + } + + static List toEntityList(List models) { + return models.map((model) => model.toEntity()).toList(); + } +} + +enum StatisticsGroupBy { + day('day', 'День'), + weekday('weekday', 'День недели'), + hour('hour', 'Час'); + + final String value; + final String label; + + const StatisticsGroupBy(this.value, this.label); + + static StatisticsGroupBy fromString(String value) { + return StatisticsGroupBy.values.firstWhere( + (e) => e.value == value, + orElse: () => StatisticsGroupBy.day, + ); + } +} + +enum StatisticsMetric { + count('count', 'Количество'), + avgExcess('avg_excess', 'Средний процент'), + avgDuration('avg_duration', 'Средняя длительность'); + + final String value; + final String label; + + const StatisticsMetric(this.value, this.label); + + static StatisticsMetric fromString(String value) { + return StatisticsMetric.values.firstWhere( + (e) => e.value == value, + orElse: () => StatisticsMetric.count, + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/data/models/warning_statistics.dart b/flutter_front/lib/features/statistics/data/models/warning_statistics.dart new file mode 100644 index 0000000..beebcf1 --- /dev/null +++ b/flutter_front/lib/features/statistics/data/models/warning_statistics.dart @@ -0,0 +1,102 @@ +import '../../domain/entities/warning_statistics_entity.dart'; + +class WarningStatisticsModel { + final Duration duration; + final ExcessPercent excessPercent; + final int totalCount; + + WarningStatisticsModel({ + required this.duration, + required this.excessPercent, + required this.totalCount, + }); + + factory WarningStatisticsModel.fromJson(Map json) { + return WarningStatisticsModel( + duration: Duration.fromJson(json['duration'] as Map), + excessPercent: ExcessPercent.fromJson(json['excess_percent'] as Map), + totalCount: json['total_count'] as int, + ); + } + + Map toJson() => { + 'duration': duration.toJson(), + 'excess_percent': excessPercent.toJson(), + 'total_count': totalCount, + }; + + WarningStatisticsEntity toEntity() => WarningStatisticsEntity( + duration: duration.toEntity(), + excessPercent: excessPercent.toEntity(), + totalCount: totalCount, + ); +} + +class Duration { + final double avg; + final double max; + final double min; + final double total; + + Duration({ + required this.avg, + required this.max, + required this.min, + required this.total, + }); + + factory Duration.fromJson(Map json) { + return Duration( + avg: json['avg'] as double, + max: json['max'] as double, + min: json['min'] as double, + total: json['total'] as double, + ); + } + + Map toJson() => { + 'avg': avg, + 'max': max, + 'min': min, + 'total': total, + }; + + DurationEntity toEntity() => DurationEntity( + avg: avg, + max: max, + min: min, + total: total, + ); +} + +class ExcessPercent { + final double avg; + final double max; + final double min; + + ExcessPercent({ + required this.avg, + required this.max, + required this.min, + }); + + factory ExcessPercent.fromJson(Map json) { + return ExcessPercent( + avg: json['avg'] as double, + max: json['max'] as double, + min: json['min'] as double, + ); + } + + Map toJson() => { + 'avg': avg, + 'max': max, + 'min': min, + }; + + ExcessPercentEntity toEntity() => ExcessPercentEntity( + avg: avg, + max: max, + min: min, + ); +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/data/repository/statistics_repository_impl.dart b/flutter_front/lib/features/statistics/data/repository/statistics_repository_impl.dart new file mode 100644 index 0000000..e5d6062 --- /dev/null +++ b/flutter_front/lib/features/statistics/data/repository/statistics_repository_impl.dart @@ -0,0 +1,52 @@ +import 'package:clean_architecture/features/statistics/domain/entities/statistics.dart'; +import 'package:dartz/dartz.dart'; +import 'package:intl/intl.dart'; + +import '../../../../core/error/failure.dart'; +import '../../domain/repositories/statistics_repository.dart'; +import '../data_source/statistics_datasource.dart'; +import '../models/statistics_model.dart'; +import '../models/warning_statistics.dart'; + +class StatisticsRepositoryImpl implements StatisticsRepository { + final StatisticsDatasource datasource; + + StatisticsRepositoryImpl({required this.datasource}); + + @override + Future>> getWarningStatistics({ + required DateTime startDate, + required DateTime endDate, + required String? equipment, + required String groupBy, + required String metric, + required double excessPercent, + }) async { + final DateFormat formatter = DateFormat('yyyy-MM-dd hh:mm'); + try { + final params = { + 'start_date': formatter.format(startDate), + 'end_date': formatter.format(endDate), + 'equipment': equipment, + 'group_by': groupBy, + 'metric': metric, + 'excess_percent': excessPercent.toString(), + }; + + final result = await datasource.getWarningStatistics(params); + return Right(StatisticsModel.toEntityList(result)); + } catch (e) { + return Left(ServerFailure()); + } + } + + @override + Future> getEquipmentWarningsStatistics(Map params) async { + try { + final res = await datasource.getEquipmentWarningsStatistics(params); + return Right(WarningStatisticsModel.fromJson(res)); + } catch (e) { + return Left(ServerFailure()); + } + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/domain/entities/statistics.dart b/flutter_front/lib/features/statistics/domain/entities/statistics.dart new file mode 100644 index 0000000..888ee56 --- /dev/null +++ b/flutter_front/lib/features/statistics/domain/entities/statistics.dart @@ -0,0 +1,9 @@ +class Statistics { + final dynamic x; + final double y; + + const Statistics({ + required this.x, + required this.y, + }); +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/domain/entities/warning_statistics_entity.dart b/flutter_front/lib/features/statistics/domain/entities/warning_statistics_entity.dart new file mode 100644 index 0000000..71ed5a5 --- /dev/null +++ b/flutter_front/lib/features/statistics/domain/entities/warning_statistics_entity.dart @@ -0,0 +1,53 @@ +import '../../../../core/types/formatDuration.dart'; + +class WarningStatisticsEntity { + final DurationEntity duration; + final ExcessPercentEntity excessPercent; + final int totalCount; + + WarningStatisticsEntity({ + required this.duration, + required this.excessPercent, + required this.totalCount, + }); +} + +class DurationEntity { + final String avgFormatted; + final String maxFormatted; + final String minFormatted; + final String totalFormatted; + final double avg; + final double max; + final double min; + final double total; + + DurationEntity({ + required this.avg, + required this.max, + required this.min, + required this.total, + }) : + avgFormatted = formatDuration(avg), + maxFormatted = formatDuration(max), + minFormatted = formatDuration(min), + totalFormatted = formatDuration(total); +} + +class ExcessPercentEntity { + final String avgFormatted; + final String maxFormatted; + final String minFormatted; + final double avg; + final double max; + final double min; + + ExcessPercentEntity({ + required this.avg, + required this.max, + required this.min, + }) : + avgFormatted = '${avg.toStringAsFixed(2)}%', + maxFormatted = '${max.toStringAsFixed(2)}%', + minFormatted = '${min.toStringAsFixed(2)}%'; +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/domain/repositories/statistics_repository.dart b/flutter_front/lib/features/statistics/domain/repositories/statistics_repository.dart new file mode 100644 index 0000000..caa6a29 --- /dev/null +++ b/flutter_front/lib/features/statistics/domain/repositories/statistics_repository.dart @@ -0,0 +1,17 @@ +import 'package:clean_architecture/features/statistics/data/models/warning_statistics.dart'; +import 'package:dartz/dartz.dart'; +import '../../../../core/error/failure.dart'; +import '../entities/statistics.dart'; + +abstract class StatisticsRepository { + Future>> getWarningStatistics({ + required DateTime startDate, + required DateTime endDate, + required String? equipment, + required String groupBy, + required String metric, + required double excessPercent, + }); + + Future> getEquipmentWarningsStatistics(Map params); +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/domain/usecases/get_equipment_warnings_statistics.dart b/flutter_front/lib/features/statistics/domain/usecases/get_equipment_warnings_statistics.dart new file mode 100644 index 0000000..aa036b4 --- /dev/null +++ b/flutter_front/lib/features/statistics/domain/usecases/get_equipment_warnings_statistics.dart @@ -0,0 +1,50 @@ +import 'dart:ffi'; + +import 'package:clean_architecture/features/statistics/data/models/warning_statistics.dart'; +import 'package:clean_architecture/features/statistics/domain/entities/warning_statistics_entity.dart'; +import 'package:clean_architecture/features/statistics/domain/repositories/statistics_repository.dart'; +import 'package:clean_architecture/shared/domain/repositories/influxdb_repository.dart'; +import 'package:dartz/dartz.dart'; +import 'package:equatable/equatable.dart'; +import 'package:intl/intl.dart'; + +import '../../../../core/error/failure.dart'; +import '../../../../core/types/influx_formater.dart'; +import '../../../../core/usecases/usecase.dart'; + +class GetEquipmentWarningsStatistics implements UseCase { + final StatisticsRepository repository; + + GetEquipmentWarningsStatistics(this.repository); + + @override + Future> call(GetEquipmentWarningsStatisticsParams params) async { + final res = await repository.getEquipmentWarningsStatistics(params.toMap()); + return res.fold( + (f) => Left(f), + (model) => Right(model.toEntity())); + } +} + +class GetEquipmentWarningsStatisticsParams extends Equatable { + final DateTime startDate; + final DateTime endDate; + final String? equipment; + const GetEquipmentWarningsStatisticsParams({ + required this.startDate, + required this.endDate, + required this.equipment, + }); + + Map toMap() { + final DateFormat formatter = DateFormat('yyyy-MM-dd hh:mm'); + return { + 'start_date': formatDateTimeForInflux(startDate), + 'end_date': formatDateTimeForInflux(endDate), + 'equipment': equipment, + }; + } + + @override + List get props => [startDate, endDate, equipment]; +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/domain/usecases/get_equipment_working_percentage.dart b/flutter_front/lib/features/statistics/domain/usecases/get_equipment_working_percentage.dart new file mode 100644 index 0000000..ec874c1 --- /dev/null +++ b/flutter_front/lib/features/statistics/domain/usecases/get_equipment_working_percentage.dart @@ -0,0 +1,45 @@ +import 'dart:ffi'; + +import 'package:clean_architecture/shared/domain/entities/percentage_entity.dart'; +import 'package:clean_architecture/shared/domain/repositories/influxdb_repository.dart'; +import 'package:dartz/dartz.dart'; +import 'package:equatable/equatable.dart'; + +import '../../../../core/error/failure.dart'; +import '../../../../core/types/influx_formater.dart'; +import '../../../../core/usecases/usecase.dart'; + +class GetEquipmentWorkingPercentage implements UseCase { + final InfluxdbRepository repository; + + GetEquipmentWorkingPercentage(this.repository); + + @override + Future> call(GetEquipmentWorkingPercentageParams params) async { + final res = await repository.getEquipmentWorkingPercentage(params.toMap()); + return res.fold((f) => Left(f), + (percent) => Right(percent.toEntity()) + ); + } +} + +class GetEquipmentWorkingPercentageParams extends Equatable { + final DateTime startDate; + final DateTime endDate; + final String? equipment; + + const GetEquipmentWorkingPercentageParams({ + required this.startDate, + required this.endDate, + required this.equipment, + }); + + Map toMap() => { + 'start_time': formatDateTimeForInflux(startDate), + 'end_time': formatDateTimeForInflux(endDate), + 'equipment': equipment, + }; + + @override + List get props => [startDate, endDate, equipment]; +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/domain/usecases/get_statistic_working_percentage.dart b/flutter_front/lib/features/statistics/domain/usecases/get_statistic_working_percentage.dart new file mode 100644 index 0000000..7399856 --- /dev/null +++ b/flutter_front/lib/features/statistics/domain/usecases/get_statistic_working_percentage.dart @@ -0,0 +1,44 @@ +import 'dart:ffi'; + +import 'package:clean_architecture/shared/domain/repositories/influxdb_repository.dart'; +import 'package:dartz/dartz.dart'; +import 'package:equatable/equatable.dart'; + +import '../../../../core/error/failure.dart'; +import '../../../../core/types/influx_formater.dart'; +import '../../../../core/usecases/usecase.dart'; + +class GetStatisticWorkingPercentageUseCase implements UseCase, GetStatisticWorkingPercentageParams> { + final InfluxdbRepository repository; + + GetStatisticWorkingPercentageUseCase(this.repository); + + @override + Future>> call(GetStatisticWorkingPercentageParams params) async { + return await repository.getStatisticWorkingPercentage(params.toMap()); + } +} + +class GetStatisticWorkingPercentageParams extends Equatable { + final DateTime startDate; + final DateTime endDate; + final String? equipment; + final String groupBy; + + const GetStatisticWorkingPercentageParams({ + required this.startDate, + required this.endDate, + required this.equipment, + required this.groupBy, + }); + + Map toMap() => { + 'start_time': formatDateTimeForInflux(startDate), + 'end_time': formatDateTimeForInflux(endDate), + 'group_by': groupBy, + 'equipment': equipment, + }; + + @override + List get props => [startDate, endDate, equipment, groupBy]; +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/domain/usecases/get_warning_statistics_usecase.dart b/flutter_front/lib/features/statistics/domain/usecases/get_warning_statistics_usecase.dart new file mode 100644 index 0000000..60079ea --- /dev/null +++ b/flutter_front/lib/features/statistics/domain/usecases/get_warning_statistics_usecase.dart @@ -0,0 +1,46 @@ +import 'package:clean_architecture/features/statistics/domain/entities/statistics.dart'; +import 'package:clean_architecture/features/statistics/domain/repositories/statistics_repository.dart'; +import 'package:dartz/dartz.dart'; +import 'package:equatable/equatable.dart'; + +import '../../../../core/error/failure.dart'; +import '../../../../core/usecases/usecase.dart'; + +class GetWarningStatisticsUseCase implements UseCase, GetWarningStatisticsParams> { + final StatisticsRepository repository; + + GetWarningStatisticsUseCase(this.repository); + + @override + Future>> call(GetWarningStatisticsParams params) async { + return await repository.getWarningStatistics( + startDate: params.startDate, + endDate: params.endDate, + equipment: params.equipment, + groupBy: params.groupBy, + metric: params.metric, + excessPercent: params.excessPercent + ); + } +} + +class GetWarningStatisticsParams extends Equatable { + final DateTime startDate; + final DateTime endDate; + final String? equipment; + final String groupBy; + final String metric; + final double excessPercent; + + const GetWarningStatisticsParams({ + required this.startDate, + required this.endDate, + required this.equipment, + required this.groupBy, + required this.metric, + required this.excessPercent, + }); + + @override + List get props => [startDate, endDate, equipment, groupBy, metric]; +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/presentation/bloc/statistics_bloc.dart b/flutter_front/lib/features/statistics/presentation/bloc/statistics_bloc.dart new file mode 100644 index 0000000..c7c81f5 --- /dev/null +++ b/flutter_front/lib/features/statistics/presentation/bloc/statistics_bloc.dart @@ -0,0 +1,270 @@ +import 'package:clean_architecture/features/statistics/data/models/statistics_model.dart'; +import 'package:clean_architecture/shared/domain/entities/equipment/equipment_list_entity.dart'; +import 'package:clean_architecture/shared/domain/usecases/get_equipment_usecase.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/types/optional.dart'; +import '../../../../shared/domain/entities/percentage_entity.dart'; +import '../../../../shared/domain/usecases/get_chosen_equipment.dart'; +import '../../../../shared/domain/usecases/get_excess_percent.dart'; +import '../../../../shared/domain/usecases/get_timerange.dart'; +import '../../../../shared/domain/usecases/no_params.dart'; +import '../../../../shared/domain/usecases/set_chosen_equipment.dart'; +import '../../../../shared/domain/usecases/set_excess_percent.dart'; +import '../../../../shared/domain/usecases/set_timerange.dart'; +import '../../../settings/presentation/widgets/settings_message.dart'; +import '../../domain/entities/statistics.dart'; +import '../../domain/entities/warning_statistics_entity.dart'; +import '../../domain/usecases/get_equipment_warnings_statistics.dart'; +import '../../domain/usecases/get_equipment_working_percentage.dart'; +import '../../domain/usecases/get_statistic_working_percentage.dart'; +import '../../domain/usecases/get_warning_statistics_usecase.dart'; + +part 'statistics_event.dart'; +part 'statistics_state.dart'; + +class StatisticsBloc extends Bloc { + final GetTimeRangeUseCase getTimeRange; + final SaveTimeRangeUseCase saveTimeRange; + final GetExcessPercentUseCase getExcessPercent; + final SetExcessPercentUseCase saveExcessPercent; + final GetChosenEquipmentUseCase getSelectedEquipment; + final SetChosenEquipmentUseCase saveSelectedEquipment; + final GetEquipmentUseCase getEquipmentList; + final GetWarningStatisticsUseCase getWarningStatistics; + final GetStatisticWorkingPercentageUseCase getStatisticWorkingPercentage; + final GetEquipmentWorkingPercentage getEquipmentWorkingPercentage; + final GetEquipmentWarningsStatistics getEquipmentWarningsStatistics; + + StatisticsBloc({ + required this.getTimeRange, + required this.saveTimeRange, + required this.getExcessPercent, + required this.saveExcessPercent, + required this.getSelectedEquipment, + required this.saveSelectedEquipment, + required this.getEquipmentList, + required this.getWarningStatistics, + required this.getStatisticWorkingPercentage, + required this.getEquipmentWorkingPercentage, + required this.getEquipmentWarningsStatistics, + }) : super(StatisticsInitial()) { + on(_onInitializeStatistics); + on(_onFetchStatistics); + on(_onUpdateGroupBy); + on(_onUpdateMetric); + on(_onSaveDateTimeRange); + on(_onSaveExcessPercent); + on(_onSaveSelectedEquipment); + } + + Future _onInitializeStatistics(InitializeStatistics event, Emitter emit) async { + emit(StatisticsLoading()); + + final dateTimeRangeResult = await getTimeRange(NoParams()); + final excessPercentResult = await getExcessPercent(NoParams()); + final selectedEquipmentResult = await getSelectedEquipment(NoParams()); + final equipmentListResult = await getEquipmentList(NoParams()); + + if (equipmentListResult.isRight()) { + final equipmentList = equipmentListResult.getOrElse(() => const EquipmentListEntity(equipment: [])); + + final double excessPercent = excessPercentResult.getOrElse(() => 0) ?? 0; + final String? selectedEquipment = selectedEquipmentResult.getOrElse(() => null); + final DateTimeRange dateTimeRange = dateTimeRangeResult.getOrElse(() { + final now = DateTime.now(); + return DateTimeRange( + start: now.subtract(const Duration(days: 7)), + end: now, + ); + }); + + emit(StatisticsLoaded( + equipmentList: equipmentList, + excessPercent: excessPercent, + selectedEquipmentKey: selectedEquipment, + startDate: dateTimeRange.start, + endDate: dateTimeRange.end, + )); + + add(FetchStatistics( + excessPercent: excessPercent, + equipmentKey: selectedEquipment, + startDate: dateTimeRange.start, + endDate: dateTimeRange.end, + )); + } else { + emit(StatisticsError("Не удалось загрузить список оборудования")); + } + } + + Future _onFetchStatistics(FetchStatistics event, Emitter emit) async { + if (state is StatisticsLoaded) { + final currentState = state as StatisticsLoaded; + + if (event.startDate == null || event.endDate == null) { + emit(currentState.copyWith( + excessPercent: event.excessPercent, + selectedEquipmentKey: Optional(event.equipmentKey), + startDate: event.startDate, + endDate: event.endDate, + )); + return; + } + + emit(StatisticsFetching(currentState)); + + final statisticsResult = await getWarningStatistics( + GetWarningStatisticsParams( + startDate: event.startDate!, + endDate: event.endDate!, + equipment: event.equipmentKey, + groupBy: currentState.selectedGroupBy.value, + metric: currentState.selectedMetric.value, + excessPercent: currentState.excessPercent, + ), + ); + + final percentResult = await getStatisticWorkingPercentage( + GetStatisticWorkingPercentageParams( + startDate: event.startDate!, + endDate: event.endDate!, + equipment: event.equipmentKey, + groupBy: currentState.selectedGroupBy.value, + ), + ); + + final equipmentPercentResult = await getEquipmentWorkingPercentage( + GetEquipmentWorkingPercentageParams( + startDate: event.startDate!, + endDate: event.endDate!, + equipment: event.equipmentKey, + ), + ); + + final warningStatisticsResult = await getEquipmentWarningsStatistics( + GetEquipmentWarningsStatisticsParams( + startDate: event.startDate!, + endDate: event.endDate!, + equipment: event.equipmentKey, + ), + ); + + + final statistics = statisticsResult.getOrElse(() => []); + final workPercentage = percentResult.getOrElse(() => []); + final equipmentPercent = equipmentPercentResult.fold((l) => null, (r) => r); + final warningStatistics = warningStatisticsResult.fold((l) => null, (r) => r); + + if (statisticsResult.isLeft() || + percentResult.isLeft() || + equipmentPercentResult.isLeft() || + warningStatisticsResult.isLeft()) { + emit(currentState.copyWith( + message: BottomMessage("Не удалось загрузить данные", isError: true), + )); + return; + } + + emit(currentState.copyWith( + statistics: statistics, + excessPercent: event.excessPercent, + selectedEquipmentKey: Optional(event.equipmentKey), + startDate: event.startDate, + endDate: event.endDate, + workPercentage: workPercentage, + equipmentPercent: equipmentPercent, + warningStatistics: warningStatistics, + )); + } + } + + Future _onSaveDateTimeRange(SaveDateTimeRange event, Emitter emit) async { + if (state is StatisticsLoaded) { + final currentState = state as StatisticsLoaded; + + final result = await saveTimeRange(DateTimeRange(start: event.startDate, end: event.endDate)); + + result.fold( + (failure) => emit(currentState.copyWith( + message: BottomMessage("Не удалось сохранить период", isError: true), + )), + (_) { + emit(currentState.copyWith( + startDate: event.startDate, + endDate: event.endDate, + )); + _refetchStatistics(currentState.copyWith( + startDate: event.startDate, + endDate: event.endDate, + )); + }, + ); + } + } + + Future _onSaveExcessPercent(SaveExcessPercentEvent event, Emitter emit) async { + if (state is StatisticsLoaded) { + final currentState = state as StatisticsLoaded; + + final result = await saveExcessPercent(event.excessPercent); + + result.fold( + (failure) => emit(currentState.copyWith( + message: BottomMessage("Не удалось сохранить процент превышения", isError: true), + )), + (_) { + emit(currentState.copyWith(excessPercent: event.excessPercent)); + _refetchStatistics(currentState.copyWith(excessPercent: event.excessPercent)); + }, + ); + } + } + + Future _onSaveSelectedEquipment(SaveSelectedEquipmentEvent event, Emitter emit) async { + if (state is StatisticsLoaded) { + final currentState = state as StatisticsLoaded; + + final result = await saveSelectedEquipment(event.equipmentKey); + + result.fold( + (failure) => emit(currentState.copyWith( + message: BottomMessage("Не удалось сохранить выбранное оборудование", isError: true), + )), + (_) { + emit(currentState.copyWith(selectedEquipmentKey: Optional(event.equipmentKey))); + _refetchStatistics(currentState.copyWith(selectedEquipmentKey: Optional(event.equipmentKey))); + }, + ); + } + } + + void _onUpdateGroupBy(UpdateGroupBy event, Emitter emit) { + if (state is StatisticsLoaded) { + final currentState = state as StatisticsLoaded; + emit(currentState.copyWith(selectedGroupBy: event.groupBy)); + _refetchStatistics(currentState.copyWith(selectedGroupBy: event.groupBy)); + } + } + + void _onUpdateMetric(UpdateMetric event, Emitter emit) { + if (state is StatisticsLoaded) { + final currentState = state as StatisticsLoaded; + emit(currentState.copyWith(selectedMetric: event.metric)); + _refetchStatistics(currentState.copyWith(selectedMetric: event.metric)); + } + } + + void _refetchStatistics(StatisticsLoaded currentState) { + if (currentState.startDate != null && + currentState.endDate != null) { + add(FetchStatistics( + excessPercent: currentState.excessPercent, + equipmentKey: currentState.selectedEquipmentKey, + startDate: currentState.startDate, + endDate: currentState.endDate, + )); + } + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/presentation/bloc/statistics_event.dart b/flutter_front/lib/features/statistics/presentation/bloc/statistics_event.dart new file mode 100644 index 0000000..d189d47 --- /dev/null +++ b/flutter_front/lib/features/statistics/presentation/bloc/statistics_event.dart @@ -0,0 +1,48 @@ +part of 'statistics_bloc.dart'; + +abstract class StatisticsEvent {} + +class InitializeStatistics extends StatisticsEvent {} + +class FetchStatistics extends StatisticsEvent { + final double excessPercent; + final String? equipmentKey; + final DateTime? startDate; + final DateTime? endDate; + + FetchStatistics({ + required this.excessPercent, + this.equipmentKey, + this.startDate, + this.endDate, + }); +} + +class SaveDateTimeRange extends StatisticsEvent { + final DateTime startDate; + final DateTime endDate; + + SaveDateTimeRange({required this.startDate, required this.endDate}); +} + +class SaveExcessPercentEvent extends StatisticsEvent { + final double excessPercent; + + SaveExcessPercentEvent({required this.excessPercent}); +} + +class SaveSelectedEquipmentEvent extends StatisticsEvent { + final String? equipmentKey; + + SaveSelectedEquipmentEvent({required this.equipmentKey}); +} + +class UpdateGroupBy extends StatisticsEvent { + final StatisticsGroupBy groupBy; + UpdateGroupBy({required this.groupBy}); +} + +class UpdateMetric extends StatisticsEvent { + final StatisticsMetric metric; + UpdateMetric({required this.metric}); +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/presentation/bloc/statistics_state.dart b/flutter_front/lib/features/statistics/presentation/bloc/statistics_state.dart new file mode 100644 index 0000000..6f0b3d9 --- /dev/null +++ b/flutter_front/lib/features/statistics/presentation/bloc/statistics_state.dart @@ -0,0 +1,80 @@ +part of 'statistics_bloc.dart'; + +abstract class StatisticsState { + final BottomMessage? message; + const StatisticsState({this.message}); +} + +class StatisticsInitial extends StatisticsState {} + +class StatisticsLoading extends StatisticsState {} + +class StatisticsFetching extends StatisticsState { + final StatisticsLoaded lastState; + + StatisticsFetching(this.lastState); +} + +class StatisticsLoaded extends StatisticsState { + final List statistics; + final double excessPercent; + final String? selectedEquipmentKey; + final StatisticsGroupBy selectedGroupBy; + final StatisticsMetric selectedMetric; + final EquipmentListEntity equipmentList; + final DateTime? startDate; + final DateTime? endDate; + final List workPercentage; + final PercentageEntity? equipmentPercent; + final WarningStatisticsEntity? warningStatistics; // Добавленное поле + + StatisticsLoaded({ + this.statistics = const [], + this.excessPercent = 0, + this.selectedEquipmentKey, + this.selectedGroupBy = StatisticsGroupBy.day, + this.selectedMetric = StatisticsMetric.count, + required this.equipmentList, + this.workPercentage = const [], + this.startDate, + this.endDate, + this.equipmentPercent, + this.warningStatistics, // Добавлено в конструктор + super.message, + }); + + StatisticsLoaded copyWith({ + List? statistics, + double? excessPercent, + Optional? selectedEquipmentKey, + StatisticsGroupBy? selectedGroupBy, + StatisticsMetric? selectedMetric, + EquipmentListEntity? equipmentList, + DateTime? startDate, + DateTime? endDate, + BottomMessage? message, + List? workPercentage, + PercentageEntity? equipmentPercent, + WarningStatisticsEntity? warningStatistics, // Добавлено в copyWith + }) { + return StatisticsLoaded( + statistics: statistics ?? this.statistics, + excessPercent: excessPercent ?? this.excessPercent, + selectedEquipmentKey: selectedEquipmentKey != null ? selectedEquipmentKey.value : this.selectedEquipmentKey, + selectedGroupBy: selectedGroupBy ?? this.selectedGroupBy, + selectedMetric: selectedMetric ?? this.selectedMetric, + equipmentList: equipmentList ?? this.equipmentList, + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + message: message ?? this.message, + workPercentage: workPercentage ?? this.workPercentage, + equipmentPercent: equipmentPercent ?? this.equipmentPercent, + warningStatistics: warningStatistics ?? this.warningStatistics, // Добавлено + ); + } +} + +class StatisticsError extends StatisticsState { + final String errorMessage; + StatisticsError(this.errorMessage); +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/presentation/pages/statistics_page.dart b/flutter_front/lib/features/statistics/presentation/pages/statistics_page.dart new file mode 100644 index 0000000..a08207b --- /dev/null +++ b/flutter_front/lib/features/statistics/presentation/pages/statistics_page.dart @@ -0,0 +1,155 @@ +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 '../bloc/statistics_bloc.dart'; +import '../widgets/control_panel.dart'; +import '../widgets/equipment_card.dart'; +import '../widgets/static_selectors.dart'; +import '../widgets/statistics_charts/statistics_chart.dart'; + +class StatisticsPage extends StatefulWidget { + const StatisticsPage({super.key}); + + @override + _StatisticsPageState createState() => _StatisticsPageState(); +} + +class _StatisticsPageState extends State { + bool _isControlPanelExpanded = true; + + @override + Widget build(BuildContext context) { + return ResponsiveScaffold( + title: "Статистика", + body: BlocProvider( + create: (_) => getIt()..add(InitializeStatistics()), + 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 StatisticsInitial || state is StatisticsLoading) { + return const Center(child: CupertinoActivityIndicator()); + } else if (state is StatisticsFetching) { + return Stack( + children: [ + AnimatedOpacity( + opacity: 0.5, // Уменьшаем прозрачность контента при загрузке + duration: const Duration(milliseconds: 300), + child: Column( + children: [ + _buildControlPanelToggle(), + if (_isControlPanelExpanded) + StatisticsControlPanel( + state: state.lastState, + isExpanded: _isControlPanelExpanded, + ), + StatisticsSelectors( + selectedGroupBy: state.lastState.selectedGroupBy, + selectedMetric: state.lastState.selectedMetric, + onGroupByChanged: (groupBy) { + context.read().add(UpdateGroupBy(groupBy: groupBy)); + }, + onMetricChanged: (metric) { + context.read().add(UpdateMetric(metric: metric)); + }, + ), + Expanded( + child: StatisticsChart( + statistics: state.lastState.statistics, + metric: state.lastState.selectedMetric, + workPercentage: state.lastState.workPercentage, + ), + ), + ], + ), + ), + const Positioned.fill( + child: Center( + child: CupertinoActivityIndicator(), + ), + ), + ], + ); + } else if (state is StatisticsLoaded) { + return SingleChildScrollView( + child: Column( + children: [ + _buildControlPanelToggle(), + if (_isControlPanelExpanded) + StatisticsControlPanel( + state: state, + isExpanded: _isControlPanelExpanded, + ), + StatisticsSelectors( + selectedGroupBy: state.selectedGroupBy, + selectedMetric: state.selectedMetric, + onGroupByChanged: (groupBy) { + context.read().add(UpdateGroupBy(groupBy: groupBy)); + }, + onMetricChanged: (metric) { + context.read().add(UpdateMetric(metric: metric)); + }, + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: WorkingPercentageCard( + percentages: state.equipmentPercent, + equipmentName: state.equipmentList.getKeysAndNames()[state.selectedEquipmentKey], + warnings: state.warningStatistics, + ), + ), + Container( + constraints: const BoxConstraints(maxWidth: 1200), + child: StatisticsChart( + statistics: state.statistics, + metric: state.selectedMetric, + workPercentage: state.workPercentage, + ), + ) + ], + ), + ); + } else if (state is StatisticsError) { + return Center(child: Text(state.errorMessage)); + } + return Container(); + }, + ), + ), + ), + ); + } + + Widget _buildControlPanelToggle() { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= 600) { + _isControlPanelExpanded = true; + return const SizedBox.shrink(); + } + return IconButton( + icon: Icon(_isControlPanelExpanded ? Icons.expand_less : Icons.expand_more), + onPressed: () { + setState(() { + _isControlPanelExpanded = !_isControlPanelExpanded; + }); + }, + ); + }, + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/presentation/widgets/control_panel.dart b/flutter_front/lib/features/statistics/presentation/widgets/control_panel.dart new file mode 100644 index 0000000..c80c62c --- /dev/null +++ b/flutter_front/lib/features/statistics/presentation/widgets/control_panel.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; + +import '../../../../shared/presentation/equipment_selector/equipment_selector.dart'; +import '../../../../shared/presentation/excess_percent_slider.dart'; +import '../bloc/statistics_bloc.dart'; + +class StatisticsControlPanel extends StatelessWidget { + final StatisticsLoaded state; + final bool isExpanded; + + const StatisticsControlPanel({ + super.key, + required this.state, + required this.isExpanded, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 600; + return Card( + margin: const EdgeInsets.all(16), + elevation: 4, + child: AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: isWide || isExpanded + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 16, + runSpacing: 16, + alignment: WrapAlignment.center, + children: [ + _buildEquipmentSelector(context), + _buildExcessPercentSlider(context), + _buildDateRangeButton(context), + ], + ), + ), + secondChild: const SizedBox.shrink(), + ), + ); + }, + ); + } + + Widget _buildEquipmentSelector(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: EquipmentSelector( + equipment: state.equipmentList.getKeysAndNames(), + selectedEquipment: state.selectedEquipmentKey, + onEquipmentChanged: (String? equipmentKey) { + context.read().add( + SaveSelectedEquipmentEvent(equipmentKey: equipmentKey), + ); + }, + ), + ); + } + + Widget _buildExcessPercentSlider(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: ExcessPercentSlider( + value: state.excessPercent, + onChanged: (value) { + context.read().add( + SaveExcessPercentEvent(excessPercent: value), + ); + }, + ), + ); + } + + Widget _buildDateRangeButton(BuildContext context) { + return Column( + children: [ + const Text("Период"), + const SizedBox(height: 5), + ElevatedButton.icon( + icon: const Icon(Icons.date_range), + label: _buildDateRangeText(), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + onPressed: () => _showDateRangePicker(context), + ), + ], + ); + } + + Widget _buildDateRangeText() { + if (state.startDate != null && state.endDate != null) { + return Text( + '${DateFormat('dd.MM.yyyy').format(state.startDate!)} - ${DateFormat('dd.MM.yyyy').format(state.endDate!)}', + style: const TextStyle(fontSize: 14), + ); + } + return const Text("Выберите даты"); + } + + void _showDateRangePicker(BuildContext context) async { + final initialDateRange = DateTimeRange( + start: state.startDate ?? DateTime.now().subtract(const Duration(days: 7)), + end: state.endDate ?? DateTime.now(), + ); + + final pickedDateRange = await showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: DateTime.now(), + initialDateRange: initialDateRange, + ); + + if (pickedDateRange != null) { + context.read().add( + SaveDateTimeRange( + startDate: pickedDateRange.start, + endDate: pickedDateRange.end, + ), + ); + } + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/presentation/widgets/custom_tooltip.dart b/flutter_front/lib/features/statistics/presentation/widgets/custom_tooltip.dart new file mode 100644 index 0000000..6a99a5c --- /dev/null +++ b/flutter_front/lib/features/statistics/presentation/widgets/custom_tooltip.dart @@ -0,0 +1,97 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../../../../core/types/formatDuration.dart'; +import '../../data/models/statistics_model.dart'; + +class CustomTooltip extends StatelessWidget { + final double barValue; + final double percentageValue; + final StatisticsMetric metric; + final Color barColor; + final Color lineColor; + final bool needPercent; + + const CustomTooltip({super.key, + required this.barValue, + required this.percentageValue, + required this.metric, + required this.barColor, + required this.lineColor, + required this.needPercent, + }); + + String _getFormattedBarValue() { + switch (metric) { + case StatisticsMetric.avgDuration: + return formatDuration(barValue); + case StatisticsMetric.count: + return barValue.toInt().toString(); + case StatisticsMetric.avgExcess: + return '${barValue.toStringAsFixed(2)}%'; + default: + return barValue.toStringAsFixed(2); + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: barColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + '${metric.label}: ${_getFormattedBarValue()}', + style: const TextStyle(fontSize: 12, color: Colors.black), + ), + ], + ), + const SizedBox(height: 2), + if(needPercent) Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: lineColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + 'Процент работы: ${percentageValue.toStringAsFixed(1)}%', + style: const TextStyle(fontSize: 12, color: Colors.black), + ), + ], + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/presentation/widgets/equipment_card.dart b/flutter_front/lib/features/statistics/presentation/widgets/equipment_card.dart new file mode 100644 index 0000000..67a6b89 --- /dev/null +++ b/flutter_front/lib/features/statistics/presentation/widgets/equipment_card.dart @@ -0,0 +1,325 @@ +import 'package:clean_architecture/features/statistics/domain/entities/warning_statistics_entity.dart'; +import 'package:flutter/material.dart'; +import '../../../../shared/domain/entities/percentage_entity.dart'; + +class WorkingPercentageCard extends StatefulWidget { + final PercentageEntity? percentages; + final WarningStatisticsEntity? warnings; + final String? equipmentName; + + const WorkingPercentageCard({ + super.key, + this.percentages, + this.equipmentName, + this.warnings, + }); + + @override + State createState() => _WorkingPercentageCardState(); +} + +class _WorkingPercentageCardState extends State { + bool _showInitialAnimation = true; + + @override + void initState() { + super.initState(); + _startAnimation(); + } + + void _startAnimation() async { + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) { + setState(() { + _showInitialAnimation = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (widget.percentages == null) return const SizedBox.shrink(); + + final sortedTypes = PercentageType.values.toList() + ..sort((a, b) => _getPercentageValue(b).compareTo(_getPercentageValue(a))); + + final maxType = sortedTypes.first; + final maxPercentage = _getPercentageValue(maxType); + + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Stack( + children: [ + // Основной контент + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.equipmentName ?? 'Все оборудование', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + Wrap( + spacing: 24, + runSpacing: 24, + children: [ + SizedBox( + width: 200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildMainPercentage( + context: context, + type: maxType, + percentage: maxPercentage, + ), + const SizedBox(height: 16), + Wrap( + spacing: 16, + runSpacing: 8, + children: sortedTypes.skip(1).map((type) => + _buildSecondaryPercentage( + context: context, + type: type, + percentage: _getPercentageValue(type), + ) + ).toList(), + ), + ], + ), + ), + IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTotalCount(context), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTimeStats(context), + const SizedBox(width: 24), + _buildPercentStats(context), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + + // Анимированный оверлей + Positioned.fill( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 500), + opacity: _showInitialAnimation ? 1.0 : 0.0, + child: Container( + decoration: BoxDecoration( + color: maxType.color.withOpacity(0.95), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + maxType.pastTranslation, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + '${maxPercentage.toStringAsFixed(1)}%', + style: Theme.of(context).textTheme.displaySmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildMainPercentage({ + required BuildContext context, + required PercentageType type, + required double percentage, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: type.color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + type.pastTranslation, + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + const SizedBox(height: 4), + Text( + '${percentage.toStringAsFixed(1)}%', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: type.color, + ), + ), + ], + ); + } + + Widget _buildSecondaryPercentage({ + required BuildContext context, + required PercentageType type, + required double percentage, + }) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: type.color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + '${type.pastTranslation}: ${percentage.toStringAsFixed(1)}%', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } + + Widget _buildTotalCount(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Количество предупреждений', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + widget.warnings!.totalCount.toString(), + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + + Widget _buildTimeStats(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'По времени', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + _buildStatRow('Среднее:', widget.warnings!.duration.avgFormatted, context), + _buildStatRow('Максимум:', widget.warnings!.duration.maxFormatted, context), + _buildStatRow('Минимум:', widget.warnings!.duration.minFormatted, context), + _buildStatRow('Общее:', widget.warnings!.duration.totalFormatted, context), + ], + ); + } + + Widget _buildPercentStats(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'По проценту', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + _buildStatRow('Среднее:', widget.warnings!.excessPercent.avgFormatted, context), + _buildStatRow('Максимум:', widget.warnings!.excessPercent.maxFormatted, context), + _buildStatRow('Минимум:', widget.warnings!.excessPercent.minFormatted, context), + ], + ); + } + + Widget _buildStatRow(String label, String value, BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .textTheme + .bodySmall + ?.color + ?.withOpacity(0.8), + ), + ), + ), + Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .textTheme + .bodySmall + ?.color + ?.withOpacity(0.8), + ), + ), + ], + ), + ); + } + + double _getPercentageValue(PercentageType type) { + switch (type) { + case PercentageType.work: + return widget.percentages!.work; + case PercentageType.turnOn: + return widget.percentages!.turnOn; + case PercentageType.turnOff: + return widget.percentages!.turnOff; + case PercentageType.notWork: + return widget.percentages!.notWork; + } + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/presentation/widgets/static_selectors.dart b/flutter_front/lib/features/statistics/presentation/widgets/static_selectors.dart new file mode 100644 index 0000000..b36e3ba --- /dev/null +++ b/flutter_front/lib/features/statistics/presentation/widgets/static_selectors.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; + +import '../../data/models/statistics_model.dart'; + +class StatisticsSelectors extends StatelessWidget { + final StatisticsGroupBy selectedGroupBy; + final StatisticsMetric selectedMetric; + final Function(StatisticsGroupBy) onGroupByChanged; + final Function(StatisticsMetric) onMetricChanged; + + const StatisticsSelectors({ + super.key, + required this.selectedGroupBy, + required this.selectedMetric, + required this.onGroupByChanged, + required this.onMetricChanged, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.all(16), + elevation: 4, + child: Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 12, + children: [ + _buildSegmentedControl( + title: 'Группировка (ось x)', + items: StatisticsGroupBy.values, + selectedItem: selectedGroupBy, + onChanged: onGroupByChanged, + getLabel: (item) => item.label, + buttonColor: Theme.of(context).primaryColor), + const SizedBox(height: 16), + _buildSegmentedControl( + title: 'Метрика превышения (ось y)', + items: StatisticsMetric.values, + selectedItem: selectedMetric, + onChanged: onMetricChanged, + getLabel: (item) => item.label, + buttonColor: Theme.of(context).primaryColor), + ], + ), + ), + ); + } + + Widget _buildSegmentedControl( + {required String title, + required List items, + required T selectedItem, + required Function(T) onChanged, + required String Function(T) getLabel, + required Color buttonColor}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + title, + style: const 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( + children: items.map((item) { + final isSelected = item == selectedItem; + 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: () => onChanged(item), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: + isSelected ? buttonColor : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + getLabel(item), + style: TextStyle( + color: isSelected ? Colors.white : Colors.black87, + fontWeight: isSelected + ? FontWeight.w500 + : FontWeight.normal, + ), + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ); + } +} diff --git a/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/bar_chart_widget.dart b/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/bar_chart_widget.dart new file mode 100644 index 0000000..3969d16 --- /dev/null +++ b/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/bar_chart_widget.dart @@ -0,0 +1,140 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../../../data/models/statistics_model.dart'; +import '../../../domain/entities/statistics.dart'; +import 'chart_formatters.dart'; + +class BarChartWidget extends StatelessWidget { + final List statistics; + final StatisticsMetric metric; + final double maxY; + final double barWidth; + final double leftTitleSize; + final double leftAxisTitleSize; + final double bottomTitleSize; + final int labelInterval; + final double interval; + final int? tooltipIndex; + final ThemeData theme; + + const BarChartWidget({ + Key? key, + required this.statistics, + required this.metric, + required this.maxY, + required this.barWidth, + required this.leftTitleSize, + required this.leftAxisTitleSize, + required this.bottomTitleSize, + required this.labelInterval, + required this.interval, + required this.tooltipIndex, + required this.theme, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return BarChart( + BarChartData( + maxY: maxY * 1.1, + alignment: BarChartAlignment.spaceAround, + barTouchData: BarTouchData(enabled: false), + titlesData: _buildTitlesData(), + borderData: FlBorderData(show: false), + gridData: _buildGridData(), + barGroups: _buildBarGroups(), + ), + ); + } + + FlTitlesData _buildTitlesData() { + return FlTitlesData( + show: true, + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: _buildBottomTitles(), + leftTitles: _buildLeftTitles(), + ); + } + + AxisTitles _buildBottomTitles() { + return AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: bottomTitleSize, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index >= statistics.length) return const SizedBox(); + if (index % labelInterval != 0) return const SizedBox(); + return Padding( + padding: const EdgeInsets.only(top: 5), + child: Text( + statistics[index].x, + style: const TextStyle(fontSize: 12), + textAlign: TextAlign.center, + ), + ); + }, + ), + ); + } + + AxisTitles _buildLeftTitles() { + return AxisTitles( + axisNameSize: leftAxisTitleSize, + axisNameWidget: Text( + metric.label, + style: const TextStyle( + fontSize: 14, + ), + ), + sideTitles: SideTitles( + showTitles: true, + maxIncluded: false, + reservedSize: leftTitleSize, + interval: interval, + getTitlesWidget: (value, meta) => + Text(ChartFormatters.getFormattedValue(metric, value)), + ), + ); + } + + + FlGridData _buildGridData() { + return FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: interval, + getDrawingHorizontalLine: (value) { + return const FlLine( + strokeWidth: 1, + dashArray: [5, 5], + ); + }, + ); + } + + List _buildBarGroups() { + return statistics.asMap().entries.map((entry) { + final index = entry.key; + final data = entry.value; + + return BarChartGroupData( + x: index, + barRods: [ + BarChartRodData( + toY: data.y, + color: tooltipIndex == index ? theme.primaryColor.withOpacity(0.8) : theme.primaryColor, + width: barWidth, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(5), + topRight: Radius.circular(5), + ), + ), + ], + ); + }).toList(); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/chart_calculations.dart b/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/chart_calculations.dart new file mode 100644 index 0000000..b646145 --- /dev/null +++ b/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/chart_calculations.dart @@ -0,0 +1,31 @@ +import '../../../data/models/statistics_model.dart'; +import '../../../domain/entities/statistics.dart'; + +class ChartCalculations { + static int calculateLabelInterval(double availableWidth, int dataLength) { + const averageLabelWidth = 100.0; + final possibleLabels = availableWidth ~/ averageLabelWidth; + if (dataLength == 0) return 1; + return (dataLength / possibleLabels).ceil(); + } + + static double calculateMaxY(List statistics, StatisticsMetric metric) { + if (statistics.isEmpty) return 0; + final maxY = statistics.map((s) => s.y).reduce((a, b) => a > b ? a : b); + if (metric == StatisticsMetric.count) { + return maxY.ceilToDouble(); + } + return maxY; + } + + static double calculateBarWidth(double availableWidth, int dataLength) { + if (dataLength == 0) return 12; + final effectiveWidth = availableWidth - 32; + double maxBarWidth = (effectiveWidth / dataLength) * 0.7; + return maxBarWidth.clamp(1.0, 25.0); + } + + static double calculateInterColumnWidth(double availableWidth, int dataLength, double leftTitleSize) { + return (availableWidth - leftTitleSize) / (dataLength * 2); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/chart_formatters.dart b/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/chart_formatters.dart new file mode 100644 index 0000000..10007bd --- /dev/null +++ b/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/chart_formatters.dart @@ -0,0 +1,17 @@ +import '../../../../../core/types/formatDuration.dart'; +import '../../../data/models/statistics_model.dart'; + +class ChartFormatters { + static String getFormattedValue(StatisticsMetric metric, double value) { + switch (metric) { + case StatisticsMetric.avgDuration: + return formatDuration(value); + case StatisticsMetric.count: + return value.toInt().toString(); + case StatisticsMetric.avgExcess: + return '${value.toStringAsFixed(2)}%'; + default: + return value.toStringAsFixed(2); + } + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/line_chart_widget.dart b/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/line_chart_widget.dart new file mode 100644 index 0000000..137faaa --- /dev/null +++ b/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/line_chart_widget.dart @@ -0,0 +1,68 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +class LineChartWidget extends StatelessWidget { + final List workPercentage; + final int dataLength; + final double leftTitleSize; + final double leftAxisTitleSize; + final double bottomTitleSize; + final double interColumnWidth; + final int? tooltipIndex; + + const LineChartWidget({ + super.key, + required this.workPercentage, + required this.dataLength, + required this.leftTitleSize, + required this.leftAxisTitleSize, + required this.bottomTitleSize, + required this.interColumnWidth, + required this.tooltipIndex, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: leftTitleSize + interColumnWidth + leftAxisTitleSize, + right: interColumnWidth, + bottom: bottomTitleSize, + ), + child: LineChart( + LineChartData( + maxY: 100*1.1, + minY: 0, + titlesData: const FlTitlesData(show: false), + lineTouchData: const LineTouchData(enabled: false), + borderData: FlBorderData(show: false), + gridData: const FlGridData(show: false), + lineBarsData: [_buildLineChartBarData()], + ), + ), + ); + } + + LineChartBarData _buildLineChartBarData() { + return LineChartBarData( + spots: List.generate( + dataLength, + (index) => FlSpot(index.toDouble(), workPercentage[index]), + ), + color: Colors.grey, + barWidth: 2, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, barData, index) { + return FlDotCirclePainter( + radius: tooltipIndex == index ? 3 : 0, + color: Colors.grey, + ); + }, + ), + isCurved: true, + preventCurveOverShooting: true, + isStrokeCapRound: true, + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/statistics_chart.dart b/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/statistics_chart.dart new file mode 100644 index 0000000..a072a11 --- /dev/null +++ b/flutter_front/lib/features/statistics/presentation/widgets/statistics_charts/statistics_chart.dart @@ -0,0 +1,194 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'dart:math' as math; + +import '../../../data/models/statistics_model.dart'; +import '../../../domain/entities/statistics.dart'; +import '../custom_tooltip.dart'; +import 'bar_chart_widget.dart'; +import 'chart_calculations.dart'; +import 'line_chart_widget.dart'; +class StatisticsChart extends StatefulWidget { + final List statistics; + final StatisticsMetric metric; + final List workPercentage; + + const StatisticsChart({ + super.key, + required this.statistics, + required this.metric, + required this.workPercentage, + }); + + @override + State createState() => _StatisticsChartState(); +} + +class _StatisticsChartState extends State { + bool _showWorkPercentage = true; + final double leftTitleSize = 70; + final double bottomTitleSize = 30; + final double leftAxisTitleSize = 25; + int? _tooltipIndex; + Offset? _tooltipPosition; + + Widget _buildTooltip(int index, Offset position, BoxConstraints constraints) { + if (index >= widget.statistics.length) return const SizedBox(); + + const tooltipWidth = 230.0; + const tooltipHeight = 100.0; + final maxX = constraints.maxWidth; + final maxY = constraints.maxHeight; + + double left = position.dx; + if (left + tooltipWidth > maxX) { + left = maxX - tooltipWidth - 16; + } + if (left < leftTitleSize) { + left = leftTitleSize + 8; + } + + double top = position.dy; + if (top + tooltipHeight > maxY) { + top = maxY - tooltipHeight - 16; + } + if (top < 8) { + top = 8; + } + + return Positioned( + left: left, + top: top, + child: CustomTooltip( + barValue: widget.statistics[index].y, + percentageValue: widget.workPercentage[index], + metric: widget.metric, + barColor: Theme.of(context).primaryColor, + lineColor: Colors.grey, + needPercent: _showWorkPercentage, + ), + ); + } + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 2, + child: LayoutBuilder( + builder: (context, constraints) { + final labelInterval = ChartCalculations.calculateLabelInterval( + constraints.maxWidth, + widget.statistics.length, + ); + + final maxY = ChartCalculations.calculateMaxY( + widget.statistics, + widget.metric, + ); + + final interval = widget.metric == StatisticsMetric.count + ? math.max((maxY / 5).ceilToDouble(), 1.0) + : math.max(maxY / 5, 0.1); + + final barWidth = ChartCalculations.calculateBarWidth( + constraints.maxWidth, + widget.statistics.length, + ); + + final interColumnWidth = ChartCalculations.calculateInterColumnWidth( + constraints.maxWidth, + widget.statistics.length, + leftTitleSize, + ); + + return Card( + margin: const EdgeInsets.all(16), + elevation: 4, + child: Padding( + padding: const EdgeInsets.all(16), + child: MouseRegion( + onHover: _handleMouseHover(constraints), + onExit: (_) { + setState(() => _tooltipIndex = null); + }, + child: Stack( + children: [ + BarChartWidget( + statistics: widget.statistics, + metric: widget.metric, + maxY: maxY, + barWidth: barWidth, + leftTitleSize: leftTitleSize, + leftAxisTitleSize: leftAxisTitleSize, + bottomTitleSize: bottomTitleSize, + labelInterval: labelInterval, + interval: interval, + tooltipIndex: _tooltipIndex, + theme: Theme.of(context), + ), + if (_showWorkPercentage) + LineChartWidget( + workPercentage: widget.workPercentage, + dataLength: widget.statistics.length, + leftTitleSize: leftTitleSize, + leftAxisTitleSize: leftAxisTitleSize, + bottomTitleSize: bottomTitleSize, + interColumnWidth: interColumnWidth, + tooltipIndex: _tooltipIndex, + ), + if (_tooltipIndex != null) + _buildTooltip(_tooltipIndex!, _tooltipPosition!, constraints), + _buildPercentageSwitch(), + ], + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildPercentageSwitch() { + return Positioned( + top: 0, + right: 0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: _showWorkPercentage, + onChanged: (value) { + setState(() => _showWorkPercentage = value); + }, + ), + const Text( + 'Показать % работы', + style: TextStyle(fontSize: 12), + ), + ], + ), + ); + } + + void Function(PointerHoverEvent) _handleMouseHover(BoxConstraints constraints) { + return (event) { + final chartWidth = constraints.maxWidth - leftTitleSize - 32; + final x = event.localPosition.dx - leftTitleSize - 16; + + if (x >= 0 && x <= chartWidth) { + final columnWidth = chartWidth / (widget.statistics.length + 2); + final index = (x / columnWidth).floor(); + + if (index != _tooltipIndex && index < widget.statistics.length) { + setState(() { + _tooltipIndex = index; + _tooltipPosition = event.localPosition; + }); + } + } else { + setState(() => _tooltipIndex = null); + } + }; + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/data/datasources/warnings_remote_datasource.dart b/flutter_front/lib/features/warnings/data/datasources/warnings_remote_datasource.dart new file mode 100644 index 0000000..fcde5dc --- /dev/null +++ b/flutter_front/lib/features/warnings/data/datasources/warnings_remote_datasource.dart @@ -0,0 +1,46 @@ +import 'package:clean_architecture/features/warnings/data/models/warning_model.dart'; +import 'package:clean_architecture/features/warnings/data/models/warnings_response_model.dart'; +import 'package:clean_architecture/shared/data/datasources/remote_datasourse.dart'; + +import '../../../../core/http/api_client.dart'; + +abstract class WarningsRemoteDataSource extends RemoteDataSource { + WarningsRemoteDataSource({required super.client}) : super(basePath: '/warnings'); + + Future getWarnings(Map params); + Future warningsViewed(Map body); + Future addDescription(Map body); +} + +class WarningsRemoteDataSourceImpl extends WarningsRemoteDataSource { + WarningsRemoteDataSourceImpl({required super.client}); + + @override + Future getWarnings(Map params) async { + final response = await makeRequest( + path: 'get_warnings', + method: RequestMethod.GET, + params: params, + ); + return WarningsResponseModel.fromJson(response); + } + + @override + Future warningsViewed(Map body) async { + return await makeRequest( + path: 'warnings_viewed', + method: RequestMethod.POST, + body: body, + ); + } + + @override + Future addDescription(Map body) async { + await makeRequest( + path: 'add_description', + method: RequestMethod.POST, + body: body, + ); + return; + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/data/models/description_model.dart b/flutter_front/lib/features/warnings/data/models/description_model.dart new file mode 100644 index 0000000..7cff89c --- /dev/null +++ b/flutter_front/lib/features/warnings/data/models/description_model.dart @@ -0,0 +1,43 @@ +import '../../domain/entities/description.dart'; + +class DescriptionModel { + final String text; + final DateTime updated; + final String author; + + DescriptionModel({ + required this.text, + required this.updated, + required this.author, + }); + + factory DescriptionModel.fromJson(Map json) { + return DescriptionModel( + text: json['text'], + updated: DateTime.parse(json['updated']), + author: json['author'], + ); + } + + Map toJson() { + return { + 'text': text, + 'updated': updated.toIso8601String(), + 'author': author, + }; + } + + factory DescriptionModel.fromEntity(Description entity) { + return DescriptionModel( + text: entity.text, + updated: entity.updated, + author: entity.author, + ); + } + + Description toEntity() => Description( + text: text, + updated: updated, + author: author, + ); +} diff --git a/flutter_front/lib/features/warnings/data/models/warning_model.dart b/flutter_front/lib/features/warnings/data/models/warning_model.dart new file mode 100644 index 0000000..baf2ab7 --- /dev/null +++ b/flutter_front/lib/features/warnings/data/models/warning_model.dart @@ -0,0 +1,63 @@ +import 'package:clean_architecture/features/warnings/domain/entities/warning.dart'; +import 'package:clean_architecture/features/warnings/domain/entities/description.dart'; +import 'package:clean_architecture/features/warnings/data/models/description_model.dart'; + +class WarningModel { + final String id; + final DateTime date; + final DateTime dateFrom; + final DateTime dateTo; + final String equipment; + final double excessPercent; + final String text; + final String type; + final String value; + final bool viewed; + final Description? description; + + WarningModel({ + required this.id, + required this.date, + required this.dateFrom, + required this.dateTo, + required this.equipment, + required this.excessPercent, + required this.text, + required this.type, + required this.value, + required this.viewed, + required this.description, + }); + + factory WarningModel.fromJson(Map json) { + return WarningModel( + id: json['_id'], + date: DateTime.parse(json['date']), + dateFrom: DateTime.parse(json['date_from']), + dateTo: DateTime.parse(json['date_to']), + equipment: json['equipment'], + excessPercent: json['excess_percent'], + text: json['text'], + type: json['type'], + value: json['value'], + viewed: json['viewed'] ?? false, + description: json['description'] != null + ? DescriptionModel.fromJson(json['description']).toEntity() + : null, + ); + } + + Warning toEntity() => Warning( + id: id, + date: date, + dateFrom: dateFrom, + dateTo: dateTo, + equipment: equipment, + excessPercent: excessPercent, + text: text, + type: type, + value: value, + viewed: viewed, + description: description, + ); +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/data/models/warnings_response_model.dart b/flutter_front/lib/features/warnings/data/models/warnings_response_model.dart new file mode 100644 index 0000000..f19b44a --- /dev/null +++ b/flutter_front/lib/features/warnings/data/models/warnings_response_model.dart @@ -0,0 +1,39 @@ +import 'package:clean_architecture/features/warnings/data/models/warning_model.dart'; + +import '../../domain/entities/warnings_data.dart'; + +class WarningsResponseModel { + final int page; + final int pages; + final int perPage; + final int total; + final List warnings; + + WarningsResponseModel({ + required this.page, + required this.pages, + required this.perPage, + required this.total, + required this.warnings, + }); + + factory WarningsResponseModel.fromJson(Map json) { + return WarningsResponseModel( + page: json['page'], + pages: json['pages'], + perPage: json['per_page'], + total: json['total'], + warnings: (json['warnings'] as List) + .map((warningJson) => WarningModel.fromJson(warningJson)) + .toList(), + ); + } + + WarningsData toEntity () => WarningsData( + page: page, + pages: pages, + perPage: perPage, + total: total, + warnings: warnings.map((w) => w.toEntity()).toList() + ); +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/data/repositories/warnings_repository_impl.dart b/flutter_front/lib/features/warnings/data/repositories/warnings_repository_impl.dart new file mode 100644 index 0000000..76ce5d5 --- /dev/null +++ b/flutter_front/lib/features/warnings/data/repositories/warnings_repository_impl.dart @@ -0,0 +1,35 @@ +import 'package:clean_architecture/features/warnings/data/datasources/warnings_remote_datasource.dart'; +import 'package:clean_architecture/features/warnings/data/models/warnings_response_model.dart'; +import 'package:clean_architecture/features/warnings/domain/entities/warning.dart'; +import 'package:clean_architecture/features/warnings/domain/entities/warnings_data.dart'; +import 'package:dartz/dartz.dart'; +import '../../../../core/error/failure.dart'; +import '../../../../shared/data/repositories/base_repository.dart'; +import '../../domain/repositories/warnings_repository.dart'; +import '../../domain/usecases/get_warnings_usecase.dart'; + +class WarningsRepositoryImpl extends BaseRepository implements WarningsRepository { + final WarningsRemoteDataSource remoteDataSource; + + WarningsRepositoryImpl({ + required this.remoteDataSource, + }); + + @override + Future> getWarnings(Map params) async { + try { + final warnings = await remoteDataSource.getWarnings(params); + return Right(warnings.toEntity()); + } catch (e) { + return Left(ServerFailure()); + } + } + + @override + Future> warningsViewed(Map body) => + performOperation(() => remoteDataSource.warningsViewed(body), ServerFailure()); + + @override + Future> addDescription(Map body) => + performOperation(() => remoteDataSource.addDescription(body), ServerFailure()); +} diff --git a/flutter_front/lib/features/warnings/domain/entities/description.dart b/flutter_front/lib/features/warnings/domain/entities/description.dart new file mode 100644 index 0000000..58e8ab9 --- /dev/null +++ b/flutter_front/lib/features/warnings/domain/entities/description.dart @@ -0,0 +1,23 @@ +class Description { + final String text; + final DateTime updated; + final String author; + + Description({ + required this.text, + required this.updated, + required this.author, + }); + + Description copyWith({ + String? text, + DateTime? updated, + String? author, + }) { + return Description( + text: text ?? this.text, + updated: updated ?? this.updated, + author: author ?? this.author, + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/domain/entities/warning.dart b/flutter_front/lib/features/warnings/domain/entities/warning.dart new file mode 100644 index 0000000..942a78e --- /dev/null +++ b/flutter_front/lib/features/warnings/domain/entities/warning.dart @@ -0,0 +1,59 @@ +import 'package:clean_architecture/features/warnings/domain/entities/description.dart'; + +const _sentinel = Object(); + +class Warning { + final String id; + final DateTime date; + final DateTime dateFrom; + final DateTime dateTo; + final String equipment; + final double excessPercent; + final String text; + final String type; + final String value; + final bool viewed; + final Description? description; + + Warning({ + required this.id, + required this.date, + required this.dateFrom, + required this.dateTo, + required this.equipment, + required this.excessPercent, + required this.text, + required this.type, + required this.value, + required this.viewed, + required this.description, + }); + + Warning copyWith({ + String? id, + DateTime? date, + DateTime? dateFrom, + DateTime? dateTo, + String? equipment, + double? excessPercent, + String? text, + String? type, + String? value, + bool? viewed, + Object? description = _sentinel, // Меняем тип на Object? и добавляем значение по умолчанию + }) { + return Warning( + id: id ?? this.id, + date: date ?? this.date, + dateFrom: dateFrom ?? this.dateFrom, + dateTo: dateTo ?? this.dateTo, + equipment: equipment ?? this.equipment, + excessPercent: excessPercent ?? this.excessPercent, + text: text ?? this.text, + type: type ?? this.type, + value: value ?? this.value, + viewed: viewed ?? this.viewed, + description: description == _sentinel ? this.description : (description as Description?), + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/domain/entities/warnings_data.dart b/flutter_front/lib/features/warnings/domain/entities/warnings_data.dart new file mode 100644 index 0000000..84a0835 --- /dev/null +++ b/flutter_front/lib/features/warnings/domain/entities/warnings_data.dart @@ -0,0 +1,41 @@ +import 'package:clean_architecture/features/warnings/domain/entities/warning.dart'; +import 'package:clean_architecture/shared/domain/entities/equipment/equipment_list_entity.dart'; + +import '../../../../shared/data/models/equipment/equipment_list_model.dart'; + +class WarningsData { + final int page; + final int pages; + final int perPage; + final int total; + final List warnings; + + late EquipmentListEntity equipment; + + WarningsData({ + required this.page, + required this.pages, + required this.perPage, + required this.total, + required this.warnings, + }); + + WarningsData copyWith({ + int? page, + int? pages, + int? perPage, + int? total, + List? warnings, + EquipmentListEntity? equipment, + }) { + final warningsData = WarningsData( + page: page ?? this.page, + pages: pages ?? this.pages, + perPage: perPage ?? this.perPage, + total: total ?? this.total, + warnings: warnings ?? this.warnings, + ); + warningsData.equipment = equipment ?? this.equipment; + return warningsData; + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/domain/repositories/warnings_repository.dart b/flutter_front/lib/features/warnings/domain/repositories/warnings_repository.dart new file mode 100644 index 0000000..8c64d52 --- /dev/null +++ b/flutter_front/lib/features/warnings/domain/repositories/warnings_repository.dart @@ -0,0 +1,12 @@ +import 'package:dartz/dartz.dart'; + +import '../../../../core/error/failure.dart'; +import '../entities/warning.dart'; +import '../entities/warnings_data.dart'; +import '../usecases/get_warnings_usecase.dart'; + +abstract class WarningsRepository { + Future> getWarnings(Map params); + Future> warningsViewed(Map body); + Future> addDescription(Map body); +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/domain/usecases/get_warnings_usecase.dart b/flutter_front/lib/features/warnings/domain/usecases/get_warnings_usecase.dart new file mode 100644 index 0000000..b321e48 --- /dev/null +++ b/flutter_front/lib/features/warnings/domain/usecases/get_warnings_usecase.dart @@ -0,0 +1,63 @@ +import 'package:dartz/dartz.dart'; +import 'package:intl/intl.dart'; + +import '../../../../core/error/failure.dart'; +import '../../../../shared/domain/repositories/equipment_repository.dart'; +import '../entities/warnings_data.dart'; +import '../repositories/warnings_repository.dart'; + +class GetWarningsUseCase { + final WarningsRepository warningsRepository; + final EquipmentRepository equipmentRepository; + + GetWarningsUseCase(this.warningsRepository, this.equipmentRepository); + + Future> call( + GetWarningsUseCaseParams params) async { + final warningsRes = + await warningsRepository.getWarnings(params.toJson()); + return warningsRes.fold((f) => Left(f), (data) async { + final equipRes = await equipmentRepository.getEquipment(); + return equipRes.fold((e) => Left(ServerFailure()), (equipment) { + data.equipment = equipment; + return Right(data); + }); + }); + } +} + +class GetWarningsUseCaseParams { + final int page; + final double excessPercent; + final String? equipmentKey; + final DateTime? startDate; + final DateTime? endDate; + final bool orderAscending; + final bool withDescription; + final bool? viewed; + + GetWarningsUseCaseParams({ + required this.page, + required this.excessPercent, + this.equipmentKey, + this.startDate, + this.endDate, + this.orderAscending = true, + this.withDescription = false, + this.viewed, + }); + + Map toJson() { + final DateFormat formatter = DateFormat('yyyy-MM-dd hh:mm'); + return { + 'page': page.toString(), + 'excess_percent': excessPercent.toString(), + if (equipmentKey != null) 'equipment_key': equipmentKey, + if (startDate != null) 'start_date': formatter.format(startDate!), + if (endDate != null) 'end_date': formatter.format(endDate!), + 'order_ascending': orderAscending.toString(), + 'with_description': withDescription.toString(), + if (viewed != null) 'viewed': viewed.toString(), + }; + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/domain/usecases/update_warning_description.dart b/flutter_front/lib/features/warnings/domain/usecases/update_warning_description.dart new file mode 100644 index 0000000..5a5f39e --- /dev/null +++ b/flutter_front/lib/features/warnings/domain/usecases/update_warning_description.dart @@ -0,0 +1,46 @@ +import 'package:clean_architecture/features/warnings/data/models/description_model.dart'; +import 'package:clean_architecture/features/warnings/domain/entities/description.dart'; +import 'package:clean_architecture/shared/domain/repositories/hive_repository.dart'; +import 'package:dartz/dartz.dart'; + +import '../../../../core/error/failure.dart'; +import '../../../../core/usecases/usecase.dart'; +import '../repositories/warnings_repository.dart'; + +class UpdateWarningDescriptionUseCase implements UseCase{ + final WarningsRepository repository; + final HiveRepository hiveRepository; + UpdateWarningDescriptionUseCase(this.repository, this.hiveRepository); + + @override + Future> call(UpdateWarningDescriptionParams param) async { + final usernameRes = await hiveRepository.getUsername(); + return usernameRes.fold( + (f) => Left(f), + (username) async { + username ??= "Неизвестно"; + final Description? description = param.descriptionText == "" ? null : + Description( + text: param.descriptionText, + updated: DateTime.now(), + author: username); + final map = { + "id": param.id, + "description": description == null ? null : DescriptionModel.fromEntity(description).toJson() + }; + final descriptionRes = await repository.addDescription(map); + return descriptionRes.fold( + (f) => Left(f), + (_) => Right(description) + ); + } + ); + } +} + +class UpdateWarningDescriptionParams{ + String descriptionText; + String id; + + UpdateWarningDescriptionParams(this.descriptionText, this.id); +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/domain/usecases/warning_viewed_usecase.dart b/flutter_front/lib/features/warnings/domain/usecases/warning_viewed_usecase.dart new file mode 100644 index 0000000..78c4825 --- /dev/null +++ b/flutter_front/lib/features/warnings/domain/usecases/warning_viewed_usecase.dart @@ -0,0 +1,28 @@ +import 'package:clean_architecture/features/warnings/domain/repositories/warnings_repository.dart'; +import 'package:dartz/dartz.dart'; + +import '../../../../core/error/failure.dart'; +import '../../../../core/usecases/usecase.dart'; +import '../entities/warning.dart'; + +class WarningViewedUseCase implements UseCase{ + final WarningsRepository repository; + WarningViewedUseCase(this.repository); + + @override + Future> call(WarningViewedParams params) async { + return repository.warningsViewed(params.toMap()); + } +} + +class WarningViewedParams { + List warnings; + bool value; + WarningViewedParams(this.warnings, this.value); + + Map toMap() { + return { + for (Warning warning in warnings) warning.id: value.toString() + }; + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/presentation/bloc/warnings_bloc.dart b/flutter_front/lib/features/warnings/presentation/bloc/warnings_bloc.dart new file mode 100644 index 0000000..9bcafcc --- /dev/null +++ b/flutter_front/lib/features/warnings/presentation/bloc/warnings_bloc.dart @@ -0,0 +1,276 @@ +import 'package:clean_architecture/features/settings/presentation/widgets/settings_message.dart'; +import 'package:clean_architecture/features/warnings/domain/entities/description.dart'; +import 'package:clean_architecture/features/warnings/domain/usecases/update_warning_description.dart'; +import 'package:clean_architecture/shared/domain/repositories/hive_repository.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/error/failure.dart'; +import '../../../../shared/domain/usecases/get_chosen_equipment.dart'; +import '../../../../shared/domain/usecases/get_excess_percent.dart'; +import '../../../../shared/domain/usecases/get_timerange.dart'; +import '../../../../shared/domain/usecases/no_params.dart'; +import '../../../../shared/domain/usecases/set_chosen_equipment.dart'; +import '../../../../shared/domain/usecases/set_excess_percent.dart'; +import '../../../../shared/domain/usecases/set_timerange.dart'; +import '../../domain/entities/warning.dart'; +import '../../domain/entities/warnings_data.dart'; +import '../../domain/usecases/get_warnings_usecase.dart'; +import '../../domain/repositories/warnings_repository.dart'; +import '../../domain/usecases/warning_viewed_usecase.dart'; + +part 'warnings_event.dart'; +part 'warnings_state.dart'; + +class WarningsBloc extends Bloc { + final GetWarningsUseCase getWarnings; + final WarningViewedUseCase warningViewed; + final UpdateWarningDescriptionUseCase updateWarningDescription; + final HiveRepository repository; + final GetTimeRangeUseCase getTimeRange; + final SaveTimeRangeUseCase saveTimeRange; + final GetExcessPercentUseCase getExcessPercent; + final SetExcessPercentUseCase saveExcessPercent; + final GetChosenEquipmentUseCase getSelectedEquipment; + final SetChosenEquipmentUseCase saveSelectedEquipment; + + WarningsBloc({ + required this.getWarnings, + required this.repository, + required this.warningViewed, + required this.updateWarningDescription, + required this.getTimeRange, + required this.saveTimeRange, + required this.getExcessPercent, + required this.saveExcessPercent, + required this.getSelectedEquipment, + required this.saveSelectedEquipment, + }) : super(WarningsInitial()) { + on(_onFetchWarnings); + on(_onInitializeWarnings); + on(_onSaveDateTimeRange); + on(_onMarkWarningAsViewed); + on(_onToggleWarningViewed); + on(_onUpdateWarningDescription); + on(_onSaveExcessPercent); + on(_onSaveSelectedEquipment); + } + + + Future _onInitializeWarnings(InitializeWarnings event, Emitter emit) async { + emit(WarningsLoading()); + + final dateTimeRangeResult = await getTimeRange(NoParams()); + final excessPercentResult = await getExcessPercent(NoParams()); + final selectedEquipmentResult = await getSelectedEquipment(NoParams()); + + Either excessPercent = excessPercentResult; + Either equipmentKey = selectedEquipmentResult; + Either dateTimeRange = dateTimeRangeResult; + + if (excessPercent.isRight() && equipmentKey.isRight() && dateTimeRange.isRight()) { + add(FetchWarnings( + page: 1, + excessPercent: excessPercent.fold((l) => 0, (r) => r ?? 0), + equipmentKey: equipmentKey.fold((l) => null, (r) => r), + startDate: dateTimeRange.fold((l) => null, (r) => r?.start), + endDate: dateTimeRange.fold((l) => null, (r) => r?.end), + )); + } + } + + Future _onFetchWarnings(FetchWarnings event, Emitter emit) async { + final currentState = state; + if (currentState is! WarningsLoaded) { + emit(WarningsLoading()); + } + + final result = await getWarnings(GetWarningsUseCaseParams( + page: event.page, + excessPercent: event.excessPercent, + equipmentKey: event.equipmentKey, + startDate: event.startDate, + endDate: event.endDate, + orderAscending: event.orderAscending, + withDescription: event.withDescription, + viewed: event.viewed, + )); + + result.fold( + (failure) { + if (currentState is WarningsLoaded) { + emit(currentState.copyWith( + message: BottomMessage('Failed to fetch warnings', isError: true) + )); + } else { + emit(WarningsError("Не удается загрузить предупреждения")); + } + }, + (warningsData) => emit(WarningsLoaded( + warningsData: warningsData, + excessPercent: event.excessPercent, + selectedEquipmentKey: event.equipmentKey, + startDate: event.startDate, + endDate: event.endDate, + orderAscending: event.orderAscending, + withDescription: event.withDescription, + viewed: event.viewed, + )), + ); + } + + Future _onSaveDateTimeRange(SaveDateTimeRange event, Emitter emit) async { + if (state is! WarningsLoaded) return; + final currentState = state as WarningsLoaded; + + final result = await repository.setDateTimeRange(DateTimeRange(start: event.startDate, end: event.endDate)); + + result.fold( + (failure) => emit(currentState.copyWith( + message: BottomMessage('Failed to save date range', isError: true) + )), + (_) => add(FetchWarnings( + page: 1, + excessPercent: currentState.excessPercent, + equipmentKey: currentState.selectedEquipmentKey, + startDate: event.startDate, + endDate: event.endDate, + )), + ); + } + + Future _onSaveExcessPercent(SaveExcessPercentEvent event, Emitter emit) async { + if (state is! WarningsLoaded) return; + final currentState = state as WarningsLoaded; + + final result = await saveExcessPercent(event.excessPercent); + + result.fold( + (failure) => emit(currentState.copyWith( + message: BottomMessage('Не удалось сохранить процент превышения', isError: true) + )), + (_) => add(FetchWarnings( + page: 1, + excessPercent: event.excessPercent, + equipmentKey: currentState.selectedEquipmentKey, + startDate: currentState.startDate, + endDate: currentState.endDate, + )), + ); + } + + Future _onSaveSelectedEquipment(SaveSelectedEquipmentEvent event, Emitter emit) async { + if (state is! WarningsLoaded) return; + final currentState = state as WarningsLoaded; + + final result = await saveSelectedEquipment(event.equipmentKey); + + result.fold( + (failure) => emit(currentState.copyWith( + message: BottomMessage('Не удалось сохранить выбранное оборудование', isError: true) + )), + (_) => add(FetchWarnings( + page: 1, + excessPercent: currentState.excessPercent, + equipmentKey: event.equipmentKey, + startDate: currentState.startDate, + endDate: currentState.endDate, + )), + ); + } + + Future _onMarkWarningAsViewed( + MarkWarningAsViewed event, + Emitter emit, + ) async { + if (state is! WarningsLoaded) return; + final currentState = state as WarningsLoaded; + + final result = await warningViewed( + WarningViewedParams([event.warning], true) + ); + + result.fold( + (failure) => emit(currentState.copyWith( + message: BottomMessage('Failed to mark warning as viewed', isError: true) + )), + (_) { + final updatedWarnings = currentState.warningsData.warnings.map((warning) { + if (warning.id == event.warning.id) { + return warning.copyWith(viewed: true); + } + return warning; + }).toList(); + + emit(currentState.copyWith( + warningsData: currentState.warningsData.copyWith( + warnings: updatedWarnings, + ), + )); + }, + ); + } + + Future _onToggleWarningViewed( + ToggleWarningViewed event, + Emitter emit, + ) async { + if (state is! WarningsLoaded) return; + final currentState = state as WarningsLoaded; + + final result = await warningViewed( + WarningViewedParams([event.warning], event.viewed) + ); + + result.fold( + (failure) => emit(currentState.copyWith( + message: BottomMessage('Failed to update warning status', isError: true) + )), + (_) { + final updatedWarnings = currentState.warningsData.warnings.map((warning) { + if (warning.id == event.warning.id) { + return warning.copyWith(viewed: event.viewed); + } + return warning; + }).toList(); + + emit(currentState.copyWith( + warningsData: currentState.warningsData.copyWith( + warnings: updatedWarnings, + ), + )); + }, + ); + } + + Future _onUpdateWarningDescription( + UpdateWarningDescription event, + Emitter emit, + ) async { + if (state is! WarningsLoaded) return; + final currentState = state as WarningsLoaded; + + final descriptionRes = await updateWarningDescription(UpdateWarningDescriptionParams(event.text, event.warning.id)); + return descriptionRes.fold( + (f) => emit(currentState.copyWith( + message: BottomMessage('Не удалось обновить описание', isError: true), + )), + (description){ + final updatedWarnings = currentState.warningsData.warnings.map((warning) { + if (warning.id == event.warning.id) { + return warning.copyWith(description: description); + } + return warning; + }).toList(); + + emit(currentState.copyWith( + warningsData: currentState.warningsData.copyWith( + warnings: updatedWarnings, + ), + message: BottomMessage('Описание успешно обновлено', isError: false), + )); + } + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/presentation/bloc/warnings_event.dart b/flutter_front/lib/features/warnings/presentation/bloc/warnings_event.dart new file mode 100644 index 0000000..6c40768 --- /dev/null +++ b/flutter_front/lib/features/warnings/presentation/bloc/warnings_event.dart @@ -0,0 +1,68 @@ +part of 'warnings_bloc.dart'; + +abstract class WarningsEvent {} + +class InitializeWarnings extends WarningsEvent {} + +class FetchWarnings extends WarningsEvent { + final int page; + final double excessPercent; + final String? equipmentKey; + final DateTime? startDate; + final DateTime? endDate; + + final bool orderAscending; + final bool withDescription; + final bool? viewed; + + FetchWarnings({ + required this.page, + required this.excessPercent, + this.equipmentKey, + this.startDate, + this.endDate, + this.orderAscending = true, + this.withDescription = false, + this.viewed + }); +} + +class SaveDateTimeRange extends WarningsEvent { + final DateTime startDate; + final DateTime endDate; + + SaveDateTimeRange({required this.startDate, required this.endDate}); +} + +class SaveExcessPercentEvent extends WarningsEvent { + final double excessPercent; + + SaveExcessPercentEvent({required this.excessPercent}); +} + +class SaveSelectedEquipmentEvent extends WarningsEvent { + final String? equipmentKey; + + SaveSelectedEquipmentEvent({required this.equipmentKey}); +} + +class MarkWarningAsViewed extends WarningsEvent { + final Warning warning; + + MarkWarningAsViewed(this.warning); +} + + +class ToggleWarningViewed extends WarningsEvent { + final Warning warning; + final bool viewed; + + ToggleWarningViewed(this.warning, this.viewed); +} + +class UpdateWarningDescription extends WarningsEvent { + final Warning warning; + final String text; + + UpdateWarningDescription(this.warning, this.text); +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/presentation/bloc/warnings_state.dart b/flutter_front/lib/features/warnings/presentation/bloc/warnings_state.dart new file mode 100644 index 0000000..725cd9f --- /dev/null +++ b/flutter_front/lib/features/warnings/presentation/bloc/warnings_state.dart @@ -0,0 +1,60 @@ +part of 'warnings_bloc.dart'; + +abstract class WarningsState { + final BottomMessage? message; + const WarningsState({this.message}); +} + +class WarningsInitial extends WarningsState {} + +class WarningsLoading extends WarningsState {} + +class WarningsLoaded extends WarningsState { + final WarningsData warningsData; + final double excessPercent; + final String? selectedEquipmentKey; + final DateTime? startDate; + final DateTime? endDate; + final bool orderAscending; + final bool withDescription; + final bool? viewed; + + WarningsLoaded({ + required this.warningsData, + this.excessPercent = 0, + this.selectedEquipmentKey, + this.startDate, + this.endDate, + this.orderAscending = true, + this.withDescription = false, + this.viewed, + }); + + WarningsLoaded copyWith({ + WarningsData? warningsData, + double? excessPercent, + String? selectedEquipmentKey, + DateTime? startDate, + DateTime? endDate, + bool? orderAscending, + bool? withDescription, + bool? viewed, + BottomMessage? message, + }) { + return WarningsLoaded( + warningsData: warningsData ?? this.warningsData, + excessPercent: excessPercent ?? this.excessPercent, + selectedEquipmentKey: selectedEquipmentKey ?? this.selectedEquipmentKey, + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + orderAscending: orderAscending ?? this.orderAscending, + withDescription: withDescription ?? this.withDescription, + viewed: viewed ?? this.viewed, + ); + } +} + +class WarningsError extends WarningsState { + String errorMessage; + WarningsError(this.errorMessage); +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/presentation/pages/warnings_page.dart b/flutter_front/lib/features/warnings/presentation/pages/warnings_page.dart new file mode 100644 index 0000000..8ed1aed --- /dev/null +++ b/flutter_front/lib/features/warnings/presentation/pages/warnings_page.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../locator_service.dart'; +import '../../../../shared/presentation/responsive_scaffold.dart'; +import '../bloc/warnings_bloc.dart'; +import '../widgets/control_panel.dart'; +import '../widgets/warnings_list.dart'; + +class WarningsPage extends StatefulWidget { + const WarningsPage({super.key}); + + @override + _WarningsPageState createState() => _WarningsPageState(); +} + +class _WarningsPageState extends State { + bool _isControlPanelExpanded = true; + + @override + Widget build(BuildContext context) { + return ResponsiveScaffold( + title: "Предупреждения", + body: BlocProvider( + create: (_) => getIt()..add(InitializeWarnings()), + 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( + buildWhen: (previous, current) => + previous.runtimeType != current.runtimeType || + (current is WarningsLoaded && previous is WarningsLoaded && + (current.warningsData != previous.warningsData || + current.excessPercent != previous.excessPercent || + current.selectedEquipmentKey != previous.selectedEquipmentKey || + current.startDate != previous.startDate || + current.endDate != previous.endDate || + current.warningsData.warnings != previous.warningsData.warnings + )), + builder: (context, state) { + if (state is WarningsInitial || state is WarningsLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is WarningsLoaded) { + return Column( + children: [ + _buildControlPanelToggle(), + if (_isControlPanelExpanded) + WarningsControlPanel( + state: state, + isExpanded: _isControlPanelExpanded, + ), + Expanded( + child: WarningList(warnings: state.warningsData.warnings), + ), + ], + ); + } else if (state is WarningsError){ + return Center(child: Text(state.errorMessage)); + } + return Container(); + }, + ), + ), + ), + ); + } + + Widget _buildControlPanelToggle() { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= 600) return const SizedBox.shrink(); + return IconButton( + icon: Icon(_isControlPanelExpanded ? Icons.expand_less : Icons.expand_more), + onPressed: () { + setState(() { + _isControlPanelExpanded = !_isControlPanelExpanded; + }); + }, + ); + }, + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/presentation/widgets/control_panel.dart b/flutter_front/lib/features/warnings/presentation/widgets/control_panel.dart new file mode 100644 index 0000000..3424219 --- /dev/null +++ b/flutter_front/lib/features/warnings/presentation/widgets/control_panel.dart @@ -0,0 +1,259 @@ +import 'package:clean_architecture/features/warnings/presentation/widgets/paggination_control.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; + +import '../../../../shared/presentation/equipment_selector/equipment_selector.dart'; +import '../../../../shared/presentation/excess_percent_slider.dart'; +import '../bloc/warnings_bloc.dart'; + +class WarningsControlPanel extends StatelessWidget { + final WarningsLoaded state; + final bool isExpanded; + + const WarningsControlPanel({ + super.key, + required this.state, + required this.isExpanded, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 600; + return Card( + margin: const EdgeInsets.all(16), + elevation: 4, + child: AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: isWide || isExpanded + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Wrap( + spacing: 16, + runSpacing: 16, + alignment: WrapAlignment.center, + children: [ + _buildEquipmentSelector(context), + _buildExcessPercentSlider(context), + _buildDateRangeButton(context), + ], + ), + const SizedBox(height: 16), + _buildFiltersRow(context), + const SizedBox(height: 16), + PaginationControls( + currentPage: state.warningsData.page, + totalPages: state.warningsData.pages, + totalItems: state.warningsData.total, + onPageChanged: (page) => _onPageChanged(context, page), + ), + ], + ), + ), + secondChild: const SizedBox.shrink(), + ), + ); + }, + ); + } + + Widget _buildEquipmentSelector(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: EquipmentSelector( + equipment: state.warningsData.equipment.getKeysAndNames(), + selectedEquipment: state.selectedEquipmentKey, + onEquipmentChanged: (String? equipmentKey) => _onEquipmentChanged(context, equipmentKey), + ), + ); + } + + Widget _buildExcessPercentSlider(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: ExcessPercentSlider( + value: state.excessPercent, + onChanged: (value) => _onExcessPercentChanged(context, value), + ), + ); + } + + Widget _buildDateRangeButton(BuildContext context) { + return Column( + children: [ + const Text("Период"), + const SizedBox(height: 5), + ElevatedButton.icon( + icon: const Icon(Icons.date_range), + label: _buildDateRangeText(), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + onPressed: () => _showDateRangePicker(context), + ), + ], + ); + } + + Widget _buildDateRangeText() { + if (state.startDate != null && state.endDate != null) { + return Text( + '${DateFormat('dd.MM.yyyy').format(state.startDate!)} - ${DateFormat('dd.MM.yyyy').format(state.endDate!)}', + style: const TextStyle(fontSize: 14), + ); + } + return const Text("Выберите даты"); + } + + Widget _buildFiltersRow(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Wrap( + spacing: 16, // Add spacing between elements + runSpacing: 16, // Add spacing between rows + alignment: WrapAlignment.center, + children: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: true, + label: Text("По возрастанию"), + ), + ButtonSegment( + value: false, + label: Text("По убыванию"), + ), + ], + selected: {state.orderAscending}, + onSelectionChanged: (Set selected) => + _onOrderChanged(context, selected.first), + ), + SegmentedButton( + segments: const [ + ButtonSegment( + value: true, + label: Text("Просмотрено"), + ), + ButtonSegment( + value: null, + label: Text("Все"), + ), + ButtonSegment( + value: false, + label: Text("Не просмотрено"), + ), + ], + selected: {state.viewed}, + onSelectionChanged: (Set selected) => + _onViewedFilterChanged(context, selected.first), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: state.withDescription, + onChanged: (value) => + _onWithDescriptionChanged(context, value ?? false), + ), + const Text("С описанием"), + ], + ), + ], + ), + ); + } + + void _onOrderChanged(BuildContext context, bool ascending) { + context.read().add(FetchWarnings( + page: state.warningsData.page, + excessPercent: state.excessPercent, + equipmentKey: state.selectedEquipmentKey, + startDate: state.startDate, + endDate: state.endDate, + orderAscending: ascending, + withDescription: state.withDescription, + viewed: state.viewed, + )); + } + + void _onWithDescriptionChanged(BuildContext context, bool withDescription) { + context.read().add(FetchWarnings( + page: state.warningsData.page, + excessPercent: state.excessPercent, + equipmentKey: state.selectedEquipmentKey, + startDate: state.startDate, + endDate: state.endDate, + orderAscending: state.orderAscending, + withDescription: withDescription, + viewed: state.viewed, + )); + } + + void _onViewedFilterChanged(BuildContext context, bool? viewed) { + context.read().add(FetchWarnings( + page: state.warningsData.page, + excessPercent: state.excessPercent, + equipmentKey: state.selectedEquipmentKey, + startDate: state.startDate, + endDate: state.endDate, + orderAscending: state.orderAscending, + withDescription: state.withDescription, + viewed: viewed, + )); + } + + void _onEquipmentChanged(BuildContext context, String? equipmentKey) { + context.read().add(FetchWarnings( + page: state.warningsData.page, + excessPercent: state.excessPercent, + equipmentKey: equipmentKey, + startDate: state.startDate, + endDate: state.endDate, + )); + context.read().add(SaveSelectedEquipmentEvent(equipmentKey: equipmentKey)); + } + + void _onExcessPercentChanged(BuildContext context, double value) { + context.read().add(FetchWarnings( + page: state.warningsData.page, + excessPercent: value, + equipmentKey: state.selectedEquipmentKey, + startDate: state.startDate, + endDate: state.endDate, + )); + context.read().add(SaveExcessPercentEvent(excessPercent: value)); + } + + void _onPageChanged(BuildContext context, int page) { + context.read().add(FetchWarnings( + page: page, + excessPercent: state.excessPercent, + equipmentKey: state.selectedEquipmentKey, + startDate: state.startDate, + endDate: state.endDate, + )); + } + + Future _showDateRangePicker(BuildContext context) async { + final result = await showDateRangePicker( + context: context, + firstDate: DateTime(2000), + lastDate: DateTime.now(), + initialDateRange: (state.startDate != null && state.endDate != null) + ? DateTimeRange(start: state.startDate!, end: state.endDate!) + : null, + ); + if (result != null && context.mounted) { + context.read().add(SaveDateTimeRange( + startDate: result.start, + endDate: result.end, + )); + } + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/presentation/widgets/description_dialog.dart b/flutter_front/lib/features/warnings/presentation/widgets/description_dialog.dart new file mode 100644 index 0000000..a757da1 --- /dev/null +++ b/flutter_front/lib/features/warnings/presentation/widgets/description_dialog.dart @@ -0,0 +1,82 @@ +import 'package:clean_architecture/features/warnings/presentation/widgets/warning_list_item.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import '../bloc/warnings_bloc.dart'; + +Future showDescriptionDialog(BuildContext context, WarningsBloc bloc, WarningListItem widget) async { + final oldString = widget.warning.description?.text ?? ''; + final TextEditingController controller = TextEditingController( + text: oldString, + ); + bool hasChanges = false; + + return showDialog( + context: context, + builder: (BuildContext context) { + return BlocProvider.value( + value: bloc, + child: BlocListener( + listenWhen: (previous, current) => + current is WarningsLoaded && + current.message != null && + !current.message!.isError, + listener: (context, state) { + Navigator.of(context).pop(); + }, + child: StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: const Text('Добавить описание'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: controller, + decoration: const InputDecoration( + hintText: 'Введите описание...', + border: OutlineInputBorder(), + ), + maxLines: 3, + onChanged: (value) { + setState(() { + hasChanges = value != oldString; + }); + }, + ), + if(widget.warning.description != null) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text("Добавлено ${widget.warning.description!.author}"), + Text(DateFormat('MM/dd HH:mm').format(widget.warning.description!.updated)), + ], + ) + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Закрыть'), + ), + TextButton( + onPressed: hasChanges + ? () { + bloc.add(UpdateWarningDescription( + widget.warning, + controller.text, + )); + } + : null, + child: const Text('Сохранить'), + ), + ], + ); + }, + ), + ), + ); + }, + ); +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/presentation/widgets/paggination_control.dart b/flutter_front/lib/features/warnings/presentation/widgets/paggination_control.dart new file mode 100644 index 0000000..d4ddafb --- /dev/null +++ b/flutter_front/lib/features/warnings/presentation/widgets/paggination_control.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +class PaginationControls extends StatelessWidget { + final int currentPage; + final int totalPages; + final int totalItems; + final Function(int) onPageChanged; + + const PaginationControls({ + super.key, + required this.currentPage, + required this.totalPages, + required this.totalItems, + required this.onPageChanged, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: currentPage > 1 + ? () => onPageChanged(currentPage - 1) + : null, + ), + Text( + 'Страница $currentPage из $totalPages', + ), + IconButton( + icon: const Icon(Icons.arrow_forward), + onPressed: currentPage < totalPages + ? () => onPageChanged(currentPage + 1) + : null, + ), + const SizedBox(width: 16), + Text( + 'Всего записей: $totalItems', + ), + ], + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/presentation/widgets/warning_list_item.dart b/flutter_front/lib/features/warnings/presentation/widgets/warning_list_item.dart new file mode 100644 index 0000000..463f1f1 --- /dev/null +++ b/flutter_front/lib/features/warnings/presentation/widgets/warning_list_item.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import '../../domain/entities/warning.dart'; +import '../bloc/warnings_bloc.dart'; +import 'description_dialog.dart'; + +class WarningListItem extends StatefulWidget { + final Warning warning; + + const WarningListItem({super.key, required this.warning}); + + @override + State createState() => _WarningListItemState(); +} + +class _WarningListItemState extends State { + bool _isHovering = false; + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + return MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + child: Card( + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + child: IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.warning.text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: widget.warning.viewed ? Colors.grey : null, + ), + ), + ), + if(widget.warning.description != null) + const Icon( + Icons.message, + color: Colors.grey, + size: 16, + ), + const SizedBox(width: 5), + if (widget.warning.viewed) + const Icon( + Icons.check, + color: Colors.green, + size: 16, + ) else + const Icon( + Icons.warning_amber_sharp, + color: Colors.orangeAccent, + size: 16, + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Период: ${widget.warning.dateFrom} - ${widget.warning.dateTo}', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 2), + Text( + 'Превышение: ${widget.warning.excessPercent.toStringAsFixed(2)}%', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: _isHovering ? 1.0 : 0.0, + child: Container( + width: 40, + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topRight: Radius.circular(10), + bottomRight: Radius.circular(10), + ), + color: _isHovering + ? Colors.grey.withOpacity(0.1) + : Colors.transparent, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + padding: EdgeInsets.zero, + icon: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: widget.warning.viewed + ? Colors.red + : Colors.green, + borderRadius: BorderRadius.circular(4), + ), + child: Icon( + widget.warning.viewed ? Icons.close : Icons.check, + color: Colors.white, + size: 16, + ), + ), + onPressed: () { + context + .read() + .add(ToggleWarningViewed( + widget.warning, + !widget.warning.viewed, + )); + }, + ), + IconButton( + padding: EdgeInsets.zero, + icon: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(4), + ), + child: const Icon( + Icons.info_outline, + size: 16, + color: Colors.black, + ), + ), + onPressed: () => showDescriptionDialog(context, bloc, widget), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/flutter_front/lib/features/warnings/presentation/widgets/warnings_list.dart b/flutter_front/lib/features/warnings/presentation/widgets/warnings_list.dart new file mode 100644 index 0000000..fa52d48 --- /dev/null +++ b/flutter_front/lib/features/warnings/presentation/widgets/warnings_list.dart @@ -0,0 +1,36 @@ +import 'package:clean_architecture/features/warnings/presentation/widgets/warning_list_item.dart'; +import 'package:flutter/material.dart'; + +import '../../../../shared/presentation/fade_in_wrapper.dart'; +import '../../domain/entities/warning.dart'; + +class WarningList extends StatelessWidget { + final List warnings; + + const WarningList({super.key, required this.warnings}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 800, // Максимальная ширина списка + maxHeight: constraints.maxHeight, + ), + child: ListView.builder( + itemCount: warnings.length, + itemBuilder: (context, index) { + return FadeInWrapper( + delayIndex: index, + child: WarningListItem(warning: warnings[index]), + ); + }, + ), + ), + ); + }, + ); + } +} \ No newline at end of file