From 03145102e1503232b12bf7daadd243f41f0c4ba3 Mon Sep 17 00:00:00 2001 From: Radomir Epur Date: Sun, 16 Feb 2025 19:16:37 +0300 Subject: [PATCH] feat: suspension control(closes #100) --- .../flows/selected_data_source_scope.dart | 5 + .../data_source/demo_data_source.dart | 63 +++++ .../data_source/blocs/change_gear_bloc.dart | 11 +- .../blocs/suspension_control_bloc.dart | 260 ++++++++++++++++++ lib/domain/data_source/data_source.dart | 2 + .../models/data_source_parameter_id.dart | 19 ++ .../package/data_source_incoming_package.dart | 7 +- .../incoming_data_source_packages.dart | 2 + .../incoming/suspension_manual_value.dart | 9 + .../package/incoming/suspension_mode.dart | 9 + .../mixins/function_id_validation_mixins.dart | 39 +-- .../models/package_data/function_id.dart | 11 - .../implementations/set_uint8_body.dart | 8 + .../package_data/wrappers/set_value.dart | 7 +- .../data_source/models/suspension_mode.dart | 103 +++++++ lib/l10n/arb/app_en.arb | 18 ++ lib/l10n/arb/app_ru.arb | 18 ++ lib/presentation/routes/main_router.dart | 1 + .../routes/subroutes/home_route.dart | 5 + .../screens/general/general_screen.dart | 15 +- .../screens/general/widgets/car_widget.dart | 7 + .../widgets/suspension_control_button.dart | 44 +++ .../widgets/suspension_control_dialog.dart | 201 ++++++++++++++ 23 files changed, 807 insertions(+), 57 deletions(-) create mode 100644 lib/domain/data_source/blocs/suspension_control_bloc.dart create mode 100644 lib/domain/data_source/models/package/incoming/suspension_manual_value.dart create mode 100644 lib/domain/data_source/models/package/incoming/suspension_mode.dart create mode 100644 lib/domain/data_source/models/suspension_mode.dart create mode 100644 lib/presentation/screens/general/widgets/suspension_control_button.dart create mode 100644 lib/presentation/screens/general/widgets/suspension_control_dialog.dart diff --git a/lib/app/scopes/flows/selected_data_source_scope.dart b/lib/app/scopes/flows/selected_data_source_scope.dart index 5d3e2b2..038531c 100644 --- a/lib/app/scopes/flows/selected_data_source_scope.dart +++ b/lib/app/scopes/flows/selected_data_source_scope.dart @@ -176,6 +176,11 @@ class SelectedDataSourceScope extends AutoRouter { ..subscribeToRightDoor() ..subscribeToWindscreenWipers(), ), + BlocProvider( + create: (context) => SuspensionControlBloc( + dataSource: context.read(), + )..add(const SuspensionControlEvent.getMode()), + ), BlocProvider( create: (context) { context.read().subscribeTo( diff --git a/lib/data/services/data_source/demo_data_source.dart b/lib/data/services/data_source/demo_data_source.dart index 6403685..0ab4a68 100644 --- a/lib/data/services/data_source/demo_data_source.dart +++ b/lib/data/services/data_source/demo_data_source.dart @@ -259,6 +259,7 @@ class DemoDataSource extends DataSource timer = Timer.periodic( Duration(milliseconds: updatePeriod), (timer) async { + if (subscriptionParameters.isEmpty) return; final innerTimerPeriod = (updatePeriod / subscriptionParameters.length).floor(); for (var i = 0; i < subscriptionParameters.length; i++) { @@ -582,6 +583,68 @@ class DemoDataSource extends DataSource }; await _sendDoorToggleResultCallback(functionId); + return const Result.value(null); + }, + ), + MainEcuMockResponseWrapper( + ids: { + const DataSourceParameterId.suspensionMode(), + }, + unavailableForSubscriptionIds: {}, + respondCallback: (id, version, manager, [package]) async { + final data = (package?.data).checkNotNull('Package data'); + final requestType = data.first; + assert( + [ + FunctionId.requestValue.value, + FunctionId.setValueWithParam.value, + ].contains(requestType), + 'Supported only "set" and "get" request types', + ); + + await manager.updateCallback( + id, + SetUint8ResultBody( + success: !generateRandomErrors() || randomBool, + value: (generateRandomErrors() || + requestType == FunctionId.requestValue.value) + ? SuspensionMode.random.id + : package?.data.last ?? 0, + ), + version, + ); + + return const Result.value(null); + }, + ), + MainEcuMockResponseWrapper( + ids: { + const DataSourceParameterId.suspensionValue(), + }, + unavailableForSubscriptionIds: {}, + respondCallback: (id, version, manager, [package]) async { + final data = (package?.data).checkNotNull('Package data'); + final requestType = data.first; + assert( + [ + FunctionId.requestValue.value, + FunctionId.setValueWithParam.value, + ].contains(requestType), + 'Supported only "set" and "get" request types', + ); + + await manager.updateCallback( + id, + SetUint8ResultBody( + success: !generateRandomErrors() || randomBool, + value: (generateRandomErrors() || + requestType == FunctionId.requestValue.value) + ? Random().nextInt(SuspensionMode.kMaxManualValue) + : package?.data.last ?? 0, + ), + version, + ); + return const Result.value(null); }, ), diff --git a/lib/domain/data_source/blocs/change_gear_bloc.dart b/lib/domain/data_source/blocs/change_gear_bloc.dart index 2fc0a62..65d91f8 100644 --- a/lib/domain/data_source/blocs/change_gear_bloc.dart +++ b/lib/domain/data_source/blocs/change_gear_bloc.dart @@ -8,17 +8,17 @@ import 'package:re_seedwork/re_seedwork.dart'; part 'change_gear_bloc.freezed.dart'; @freezed -class ChangeGearEvent extends EffectEvent with _$ChangeGearEvent { +class ChangeGearEvent with _$ChangeGearEvent { const factory ChangeGearEvent.change(MotorGear newGear) = _Change; } typedef ChangeGearState = AsyncData; -class ChangeGearBloc extends Bloc - with BlocEventHandlerMixin { +class ChangeGearBloc extends Bloc { ChangeGearBloc({ required this.dataSource, required this.generalDataCubit, + this.responseTimeout = const Duration(seconds: 2), }) : super(const ChangeGearState.initial(MotorGear.unknown)) { on<_Change>(_onChange); } @@ -37,6 +37,9 @@ class ChangeGearBloc extends Bloc @protected final GeneralDataCubit generalDataCubit; + @visibleForTesting + final Duration responseTimeout; + Future _onChange( _Change event, Emitter> emit, @@ -46,7 +49,7 @@ class ChangeGearBloc extends Bloc try { final future = generalDataCubit.stream .firstWhere((element) => element.mergedGear == event.newGear) - .timeout(const Duration(seconds: 2)); + .timeout(responseTimeout); for (final parameterId in kParameterIds) { final res = await dataSource.sendPackage( OutgoingSetValuePackage( diff --git a/lib/domain/data_source/blocs/suspension_control_bloc.dart b/lib/domain/data_source/blocs/suspension_control_bloc.dart new file mode 100644 index 0000000..5d7adbc --- /dev/null +++ b/lib/domain/data_source/blocs/suspension_control_bloc.dart @@ -0,0 +1,260 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:pixel_app_flutter/domain/data_source/data_source.dart'; +import 'package:pixel_app_flutter/domain/data_source/models/package/incoming/incoming_data_source_packages.dart'; +import 'package:pixel_app_flutter/domain/data_source/models/package/outgoing/outgoing_data_source_packages.dart'; +import 'package:pixel_app_flutter/domain/data_source/models/package_data/package_data.dart'; +import 'package:re_seedwork/re_seedwork.dart'; + +part 'suspension_control_bloc.freezed.dart'; + +@freezed +class SuspensionControlEvent extends EffectEvent with _$SuspensionControlEvent { + const factory SuspensionControlEvent.switchMode(SuspensionMode mode) = + _SwitchMode; + const factory SuspensionControlEvent.getManual() = _GetManual; + const factory SuspensionControlEvent.setManual(int value) = _SetManual; + const factory SuspensionControlEvent.getMode() = _GetMode; +} + +typedef SuspensionControlState + = AsyncData; + +class SuspensionControlBloc + extends Bloc { + SuspensionControlBloc({ + required this.dataSource, + this.responseTimeout = const Duration(seconds: 2), + }) : super( + const SuspensionControlState.initial( + SuspensionMode.manualMiddle(), + ), + ) { + on<_GetMode>(_onGetMode); + on<_SwitchMode>(_onSwitchMode); + on<_GetManual>(_onGetManualValue); + on<_SetManual>(_onSetManualValue); + } + + @protected + final DataSource dataSource; + + @visibleForTesting + final Duration responseTimeout; + + Future _onGetMode( + _GetMode event, + Emitter> emit, + ) async { + emit(state.inLoading()); + + try { + await dataSource.packageStream + .waitFor( + action: () async { + final result = await dataSource.sendPackage( + OutgoingValueRequestPackage( + parameterId: const DataSourceParameterId.suspensionMode(), + ), + ); + + if (result.isError) { + emit(state.inFailure(event)); + } + return result.isError; + }, + onDone: (package) async { + emit( + package.dataModel.when( + success: (value) { + return AsyncData.success(SuspensionMode.fromId(value)); + }, + error: () => state.inFailure(event), + ), + ); + }, + timeout: responseTimeout, + ); + } catch (e) { + emit(state.inFailure(event)); + + rethrow; + } + + if (state.isSuccess && state.value.isManual) { + add(const SuspensionControlEvent.getManual()); + } + } + + Future _onGetManualValue( + _GetManual event, + Emitter> emit, + ) async { + emit(const AsyncData.loading(SuspensionMode.manualMiddle())); + + try { + await dataSource.packageStream + .waitFor( + action: () async { + final result = await dataSource.sendPackage( + OutgoingValueRequestPackage( + parameterId: const DataSourceParameterId.suspensionValue(), + ), + ); + + if (result.isError) { + emit(state.inFailure(event)); + } + return result.isError; + }, + onDone: (package) async { + emit( + package.dataModel.when( + success: (value) { + return AsyncData.success(SuspensionMode.manual(value: value)); + }, + error: () => state.inFailure(event), + ), + ); + }, + timeout: responseTimeout, + ); + } catch (e) { + emit(state.inFailure(event)); + + rethrow; + } + } + + Future _onSwitchMode( + _SwitchMode event, + Emitter> emit, + ) async { + final beforeMode = state.payload; + emit(AsyncData.loading(event.mode)); + + try { + await dataSource.packageStream + .waitFor( + action: () async { + final result = await dataSource.sendPackage( + OutgoingSetValuePackage( + parameterId: const DataSourceParameterId.suspensionMode(), + setValueBody: SetUint8Body(value: event.mode.id), + ), + ); + + if (result.isError) { + emit(AsyncData.failure(beforeMode, event)); + } + return result.isError; + }, + onDone: (package) async { + emit( + package.dataModel.when( + success: (value) { + if (value == state.payload.id) { + return state.inSuccess(); + } + return AsyncData.failure( + beforeMode, + event, + ); + }, + error: () => AsyncData.failure( + beforeMode, + event, + ), + ), + ); + }, + timeout: responseTimeout, + ); + } catch (e) { + emit(AsyncData.failure(beforeMode, event)); + + rethrow; + } + + if (state.isSuccess && state.payload.isManual) { + add(const SuspensionControlEvent.getManual()); + } + } + + Future _onSetManualValue( + _SetManual event, + Emitter> emit, + ) async { + final beforeMode = state.payload; + emit(AsyncData.loading(SuspensionMode.manual(value: event.value))); + + try { + await dataSource.packageStream + .waitFor( + action: () async { + final result = await dataSource.sendPackage( + OutgoingSetValuePackage( + parameterId: const DataSourceParameterId.suspensionValue(), + setValueBody: SetUint8Body(value: event.value), + ), + ); + + if (result.isError) { + emit( + AsyncData.failure( + beforeMode, + event, + ), + ); + } + return result.isError; + }, + onDone: (package) async { + emit( + package.dataModel.when( + success: (value) { + if (value == event.value) { + return state.inSuccess(); + } + return AsyncData.failure( + beforeMode, + event, + ); + }, + error: () => AsyncData.failure( + beforeMode, + event, + ), + ), + ); + }, + timeout: responseTimeout, + ); + } catch (e) { + emit( + AsyncData.failure( + beforeMode, + event, + ), + ); + + rethrow; + } + } +} + +extension on Stream { + Future waitFor({ + required Future Function() action, + required Future Function(T value) onDone, + required Duration timeout, + }) async { + final future = firstWhere((package) => package is T).timeout(timeout); + + final stop = await action(); + + if (stop) return; + + await onDone((await future) as T); + } +} diff --git a/lib/domain/data_source/data_source.dart b/lib/domain/data_source/data_source.dart index d233b71..07189e1 100644 --- a/lib/domain/data_source/data_source.dart +++ b/lib/domain/data_source/data_source.dart @@ -14,6 +14,7 @@ export 'blocs/lights_cubit.dart'; export 'blocs/motor_data_cubit.dart'; export 'blocs/outgoing_packages_cubit.dart'; export 'blocs/select_data_source_bloc.dart'; +export 'blocs/suspension_control_bloc.dart'; export 'blocs/toggle_state_error.dart'; // models @@ -29,6 +30,7 @@ export 'models/int_with_status.dart'; export 'models/package/data_source_package.dart'; export 'models/package_data/bytes_converter.dart'; export 'models/serial_number.dart'; +export 'models/suspension_mode.dart'; export 'models/usb_port_parameters.dart'; // services diff --git a/lib/domain/data_source/models/data_source_parameter_id.dart b/lib/domain/data_source/models/data_source_parameter_id.dart index 174a0e6..ba86c69 100644 --- a/lib/domain/data_source/models/data_source_parameter_id.dart +++ b/lib/domain/data_source/models/data_source_parameter_id.dart @@ -168,6 +168,11 @@ abstract class DataSourceParameterId { const factory DataSourceParameterId.windscreenWipers() = WindscreenWipersParameterId; + const factory DataSourceParameterId.suspensionMode() = + SuspensionModeParameterId; + const factory DataSourceParameterId.suspensionValue() = + SuspensionValueParameterId; + bool get isAuthorization => this is AuthorizationParameterId; bool get isSpeed => this is SpeedParameterId; @@ -292,6 +297,9 @@ abstract class DataSourceParameterId { bool get isWindscreenWipers => this is WindscreenWipersParameterId; + bool get isSuspensionMode => this is SuspensionModeParameterId; + bool get isSuspensionValue => this is SuspensionValueParameterId; + void voidOn(void Function() function) { if (this is T) function(); } @@ -383,6 +391,9 @@ abstract class DataSourceParameterId { DataSourceParameterId.cabinLight(), // DataSourceParameterId.windscreenWipers(), + // + DataSourceParameterId.suspensionMode(), + DataSourceParameterId.suspensionValue(), ]; } @@ -719,3 +730,11 @@ class CabinLightParameterId extends DataSourceParameterId { class WindscreenWipersParameterId extends DataSourceParameterId { const WindscreenWipersParameterId() : super(0x00CA); } + +class SuspensionModeParameterId extends DataSourceParameterId { + const SuspensionModeParameterId() : super(0x0244); +} + +class SuspensionValueParameterId extends DataSourceParameterId { + const SuspensionValueParameterId() : super(0x0245); +} diff --git a/lib/domain/data_source/models/package/data_source_incoming_package.dart b/lib/domain/data_source/models/package/data_source_incoming_package.dart index 18a7978..94ab38a 100644 --- a/lib/domain/data_source/models/package/data_source_incoming_package.dart +++ b/lib/domain/data_source/models/package/data_source_incoming_package.dart @@ -122,6 +122,9 @@ abstract class DataSourceIncomingPackage // WindscreenWipersIncomingDataSourcePackage.new, // + SuspensionModeIncomingDataSourcePackage.new, + SuspensionManualValueIncomingDataSourcePackage.new, + // ErrorWithCodeAndSectionIncomingDataSourcePackage.new, // CustomIncomingDataSourcePackage.new, @@ -150,8 +153,8 @@ extension VoidOnModelExtension on DataSourceIncomingPackage { abstract class SetUint8ResultIncomingDataSourcePackage extends DataSourceIncomingPackage with - IsEventOrSubscriptionAnswerRequestTypeMixin, - IsSuccessEventFunctionIdMixin, + IsEventOrBufferRequestOrSubscriptionAnswerRequestTypeMixin, + IsSuccessEventOrErrorEventFunctionIdMixin, SetUint8ResultBodyBytesConverterMixin { SetUint8ResultIncomingDataSourcePackage(super.source); } diff --git a/lib/domain/data_source/models/package/incoming/incoming_data_source_packages.dart b/lib/domain/data_source/models/package/incoming/incoming_data_source_packages.dart index d09819c..0d2e300 100644 --- a/lib/domain/data_source/models/package/incoming/incoming_data_source_packages.dart +++ b/lib/domain/data_source/models/package/incoming/incoming_data_source_packages.dart @@ -29,6 +29,8 @@ export 'reverse_light.dart'; export 'rpm.dart'; export 'side_beam.dart'; export 'speed.dart'; +export 'suspension_manual_value.dart'; +export 'suspension_mode.dart'; export 'turn_signal.dart'; export 'voltage.dart'; export 'windscreen_wipers.dart'; diff --git a/lib/domain/data_source/models/package/incoming/suspension_manual_value.dart b/lib/domain/data_source/models/package/incoming/suspension_manual_value.dart new file mode 100644 index 0000000..ab125a3 --- /dev/null +++ b/lib/domain/data_source/models/package/incoming/suspension_manual_value.dart @@ -0,0 +1,9 @@ +import 'package:pixel_app_flutter/domain/data_source/data_source.dart'; + +class SuspensionManualValueIncomingDataSourcePackage + extends SetUint8ResultIncomingDataSourcePackage { + SuspensionManualValueIncomingDataSourcePackage(super.source); + + @override + bool get validParameterId => parameterId.isSuspensionValue; +} diff --git a/lib/domain/data_source/models/package/incoming/suspension_mode.dart b/lib/domain/data_source/models/package/incoming/suspension_mode.dart new file mode 100644 index 0000000..b3fbcc0 --- /dev/null +++ b/lib/domain/data_source/models/package/incoming/suspension_mode.dart @@ -0,0 +1,9 @@ +import 'package:pixel_app_flutter/domain/data_source/data_source.dart'; + +class SuspensionModeIncomingDataSourcePackage + extends SetUint8ResultIncomingDataSourcePackage { + SuspensionModeIncomingDataSourcePackage(super.source); + + @override + bool get validParameterId => parameterId.isSuspensionMode; +} diff --git a/lib/domain/data_source/models/package/mixins/function_id_validation_mixins.dart b/lib/domain/data_source/models/package/mixins/function_id_validation_mixins.dart index c70a776..95bd343 100644 --- a/lib/domain/data_source/models/package/mixins/function_id_validation_mixins.dart +++ b/lib/domain/data_source/models/package/mixins/function_id_validation_mixins.dart @@ -1,5 +1,4 @@ import 'package:pixel_app_flutter/domain/data_source/data_source.dart'; -import 'package:pixel_app_flutter/domain/data_source/models/package_data/data_source_package_data_exceptions.dart'; import 'package:pixel_app_flutter/domain/data_source/models/package_data/package_data.dart'; mixin IsPeriodicValueStatusFunctionIdMixin @@ -16,42 +15,18 @@ mixin IsSuccessEventFunctionIdMixin data.isNotEmpty && data[0] == FunctionId.okEventId; } -mixin IsPeriodicValueStatusOrSuccessEventFunctionIdMixin< - T extends BytesConvertible> on DataSourceIncomingPackage { +mixin IsSuccessEventOrErrorEventFunctionIdMixin + on DataSourceIncomingPackage { @override bool get validFunctionId => data.isNotEmpty && - (data[0] == FunctionId.okEventId || PeriodicValueStatus.isValid(data[0])); + (data[0] == FunctionId.okEventId || data[0] == FunctionId.errorEventId); } -mixin IsSetResponseFunctionIdMixin - on DataSourceIncomingPackage { - static const kSetResponseFunctionIds = [ - FunctionId.successSetValueWithParamId, - FunctionId.errorSettingValueWithParamId, - ]; - +mixin IsPeriodicValueStatusOrSuccessEventFunctionIdMixin< + T extends BytesConvertible> on DataSourceIncomingPackage { @override bool get validFunctionId => - data.isNotEmpty && kSetResponseFunctionIds.contains(data[0]); - - R whenFunctionId({ - required R Function(List data) error, - required R Function(List data) success, - }) { - final functionId = data.isEmpty ? null : data[0]; - final _data = data.isEmpty ? [] : data.sublist(1); - switch (functionId) { - case FunctionId.successSetValueWithParamId: - return success(_data); - case FunctionId.errorSettingValueWithParamId: - return error(_data); - default: - throw UnexpectedFunctionIdDataSourcePackageDataException( - expected: kSetResponseFunctionIds, - functionId: functionId, - package: this, - ); - } - } + data.isNotEmpty && + (data[0] == FunctionId.okEventId || PeriodicValueStatus.isValid(data[0])); } diff --git a/lib/domain/data_source/models/package_data/function_id.dart b/lib/domain/data_source/models/package_data/function_id.dart index 40c48f5..9b40011 100644 --- a/lib/domain/data_source/models/package_data/function_id.dart +++ b/lib/domain/data_source/models/package_data/function_id.dart @@ -13,10 +13,6 @@ enum FunctionId { /// Pakcages with this function id should be sent with no data. action(0x03), - /// ID, saying that value was set successfuly. - /// This id comes as result of [setValueWithParam] - successSetValueWithParam(successSetValueWithParamId), - /// ID, saying that value request was successful. /// This id comes as result of [requestValue] successRequestValue(0x51), @@ -34,10 +30,6 @@ enum FunctionId { /// is "critical"(ex: the temperature of the battery is too high) criticalIncomingPeriodicValue(criticalIncomingPeriodicValueId), - /// ID, saying that an error was encountered while setting a value, - /// This id comes as result of [setValueWithParam]. - errorSettingValueWithParam(errorSettingValueWithParamId), - /// ID, saying that an error was encountered while requesting a value, /// This id comes as result of [requestValue]. errorRequestingValue(0xD1), @@ -62,9 +54,6 @@ enum FunctionId { static const warningIncomingPeriodicValueId = 0x62; static const criticalIncomingPeriodicValueId = 0x63; // - static const successSetValueWithParamId = 0x41; - static const errorSettingValueWithParamId = 0xC1; - // static const errorEventId = 0xE6; static const okEventId = 0x65; } diff --git a/lib/domain/data_source/models/package_data/implementations/set_uint8_body.dart b/lib/domain/data_source/models/package_data/implementations/set_uint8_body.dart index b985b83..8749407 100644 --- a/lib/domain/data_source/models/package_data/implementations/set_uint8_body.dart +++ b/lib/domain/data_source/models/package_data/implementations/set_uint8_body.dart @@ -50,6 +50,14 @@ class SetUint8ResultBody extends SetValueResult { final int value; + T when({ + required T Function(int value) success, + required T Function() error, + }) { + if (this.success) return success(value); + return error(); + } + static SetValueResultConverter get converter => const SetUint8ResultBodyConverter(); diff --git a/lib/domain/data_source/models/package_data/wrappers/set_value.dart b/lib/domain/data_source/models/package_data/wrappers/set_value.dart index 6831f2d..eda4886 100644 --- a/lib/domain/data_source/models/package_data/wrappers/set_value.dart +++ b/lib/domain/data_source/models/package_data/wrappers/set_value.dart @@ -55,7 +55,7 @@ abstract class SetValueResultConverter @override T fromBytes(List bytes) { return fromBytesResult( - success: bytes[0] == FunctionId.successSetValueWithParamId, + success: bytes[0] == FunctionId.okEventId, body: bytes.sublist(1), ); } @@ -65,10 +65,7 @@ abstract class SetValueResultConverter @override List toBytes(T model) { return [ - if (model.success) - FunctionId.successSetValueWithParamId - else - FunctionId.errorSettingValueWithParamId, + if (model.success) FunctionId.okEventId else FunctionId.errorEventId, ...toBytesBody(model), ]; } diff --git a/lib/domain/data_source/models/suspension_mode.dart b/lib/domain/data_source/models/suspension_mode.dart new file mode 100644 index 0000000..5f59593 --- /dev/null +++ b/lib/domain/data_source/models/suspension_mode.dart @@ -0,0 +1,103 @@ +import 'dart:math'; + +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +@immutable +sealed class SuspensionMode with EquatableMixin { + const SuspensionMode({required this.id}); + + const factory SuspensionMode.low() = _LowSuspensionMode; + const factory SuspensionMode.highway() = _HighwaySuspensionMode; + const factory SuspensionMode.offRoad() = _OffRoadSuspensionMode; + const factory SuspensionMode.manual({required int value}) = + _ManualSuspensionMode; + const factory SuspensionMode.manualMiddle() = _ManualSuspensionMode.middle; + + factory SuspensionMode.fromId(int id) { + return values.firstWhere((element) => element.id == id); + } + + static const List values = [ + SuspensionMode.low(), + SuspensionMode.highway(), + SuspensionMode.offRoad(), + SuspensionMode.manualMiddle(), + ]; + + final int id; + + static const kMaxManualValue = 255; + + bool get isManual => this is _ManualSuspensionMode; + + static SuspensionMode get random => values[Random().nextInt(values.length)]; + + T when({ + required T Function() low, + required T Function() highway, + required T Function() offRoad, + required T Function(int value) manual, + }) { + return switch (this) { + _LowSuspensionMode() => low(), + _HighwaySuspensionMode() => highway(), + _OffRoadSuspensionMode() => offRoad(), + _ManualSuspensionMode(value: final int value) => manual(value), + }; + } + + T maybeWhen({ + required T Function() orElse, + T Function()? low, + T Function()? highway, + T Function()? offRoad, + T Function(int value)? manual, + }) { + return switch (this) { + _LowSuspensionMode() => low?.call() ?? orElse(), + _HighwaySuspensionMode() => highway?.call() ?? orElse(), + _OffRoadSuspensionMode() => offRoad?.call() ?? orElse(), + _ManualSuspensionMode(value: final int value) => + manual?.call(value) ?? orElse(), + }; + } + + @override + List get props => [id]; +} + +final class _LowSuspensionMode extends SuspensionMode { + const _LowSuspensionMode() : super(id: 1); +} + +final class _HighwaySuspensionMode extends SuspensionMode { + const _HighwaySuspensionMode() : super(id: 2); +} + +final class _OffRoadSuspensionMode extends SuspensionMode { + const _OffRoadSuspensionMode() : super(id: 4); +} + +final class _ManualSuspensionMode extends SuspensionMode { + const _ManualSuspensionMode({required this.value}) + : assert( + value >= 0 && SuspensionMode.kMaxManualValue <= 255, + 'Must be Uint8', + ), + super(id: kId); + + const _ManualSuspensionMode.middle() + : value = 128, + super(id: kId); + + static const kId = 128; + + final int value; + + @override + List get props => [ + ...super.props, + value, + ]; +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 960db92..906b893 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -65,6 +65,7 @@ "saveButtonCaption": "Save", "sendButtonCaption": "Send", "enableButtonCaption": "Enable", + "retryButtonCaption": "Retry", "unknownErrorSelectingDataSourceMessage": "Unknown error while selecting data source", "unavailableDataSourceErrorMessage": "Data source is unavailable", "errorEnablingDataSourceMessage": "Error while enabling data source", @@ -298,6 +299,23 @@ "unknownMotorRollDirection": "Unknown", "forwardMotorRollDirection": "Forward", "stopMotorRollDirection": "Stop", + "suspensionModeDialogTitle": "Suspension mode", + "errorGettingSuspensionModeMessage": "Error getting suspension mode", + "errorSwitchingSuspensionModeMessage": "Error switching suspension mode", + "errorGettingManualValueMessage": "Error getting manual mode value", + "errorSettingManualValueMessage": "Error setting manual mode value", + "lowSuspensionMode": "Low", + "highwaySuspensionMode": "Highway", + "offRoadSuspensionMode": "Off road", + "manualSuspensionMode": "Manual", + "manualValueSuspensionMode": "Manual({value})", + "@manualValueSuspensionMode": { + "placeholders": { + "value": { + "type": "int" + } + } + }, "ledPanelSwitcherDialogTitle": "Run LED panel configurations", "noLEDConfigurationsMessage": "There are no configurations yet", "shutdownConfigByTimerMessage": "By timer: {shutdownTimeMillis} ms", diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 7791bb8..153e4a0 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -65,6 +65,7 @@ "saveButtonCaption": "Сохранить", "sendButtonCaption": "Отправить", "enableButtonCaption": "Включить", + "retryButtonCaption": "Еще раз", "unknownErrorSelectingDataSourceMessage": "Неизвестная ошибка при выборе источника данных", "unavailableDataSourceErrorMessage": "Источник данных недоступен", "errorEnablingDataSourceMessage": "Ошибка при включении источника данных", @@ -298,6 +299,23 @@ "unknownMotorRollDirection": "Неизвестно", "forwardMotorRollDirection": "Вперед", "stopMotorRollDirection": "Стоп", + "suspensionModeDialogTitle": "Режим подвески", + "errorGettingSuspensionModeMessage": "Ошибка при получении режима подвески", + "errorSwitchingSuspensionModeMessage": "Ошибка при переключении режима подвески", + "errorGettingManualValueMessage": "Ошибка при получении значения ручного режима", + "errorSettingManualValueMessage": "Ошибка при установке значения ручного режима", + "lowSuspensionMode": "Заниженный", + "highwaySuspensionMode": "Шоссе", + "offRoadSuspensionMode": "Оффроуд", + "manualSuspensionMode": "Ручной", + "manualValueSuspensionMode": "Ручной({value})", + "@manualValueSuspensionMode": { + "placeholders": { + "value": { + "type": "int" + } + } + }, "ledPanelSwitcherDialogTitle": "Запуск конфигураций светодиодной панели", "noLEDConfigurationsMessage": "Пока конфигураций нет", "shutdownConfigByTimerMessage": "По таймеру: {shutdownTimeMillis} мс", diff --git a/lib/presentation/routes/main_router.dart b/lib/presentation/routes/main_router.dart index cd2406c..39664c4 100644 --- a/lib/presentation/routes/main_router.dart +++ b/lib/presentation/routes/main_router.dart @@ -32,6 +32,7 @@ import 'package:pixel_app_flutter/presentation/screens/developer_tools/widgets/s import 'package:pixel_app_flutter/presentation/screens/general/general_screen.dart'; import 'package:pixel_app_flutter/presentation/screens/general/widgets/change_gear_dialog.dart'; import 'package:pixel_app_flutter/presentation/screens/general/widgets/led_panel_switcher_dialog.dart'; +import 'package:pixel_app_flutter/presentation/screens/general/widgets/suspension_control_dialog.dart'; import 'package:pixel_app_flutter/presentation/screens/home/home_screen.dart'; import 'package:pixel_app_flutter/presentation/screens/navigator/navigator_screen.dart'; import 'package:pixel_app_flutter/presentation/screens/navigator/widgets/enable_fast_access_dialog.dart'; diff --git a/lib/presentation/routes/subroutes/home_route.dart b/lib/presentation/routes/subroutes/home_route.dart index 3a1adad..1f7b540 100644 --- a/lib/presentation/routes/subroutes/home_route.dart +++ b/lib/presentation/routes/subroutes/home_route.dart @@ -17,6 +17,11 @@ final _homeRoute = AutoRoute( page: LEDSwitcherDialogRoute.page, customRouteBuilder: noBarrierDialogRouteBuilder, ), + CustomRoute( + path: 'suspension-control-dialog', + page: SuspensionControlDialogRoute.page, + customRouteBuilder: noBarrierDialogRouteBuilder, + ), ], ), AutoRoute( diff --git a/lib/presentation/screens/general/general_screen.dart b/lib/presentation/screens/general/general_screen.dart index 804f3c2..4233c20 100644 --- a/lib/presentation/screens/general/general_screen.dart +++ b/lib/presentation/screens/general/general_screen.dart @@ -8,6 +8,7 @@ import 'package:pixel_app_flutter/presentation/screens/general/widgets/general_i import 'package:pixel_app_flutter/presentation/screens/general/widgets/led_switcher_button.dart'; import 'package:pixel_app_flutter/presentation/screens/general/widgets/light_state_error_listener.dart'; import 'package:pixel_app_flutter/presentation/screens/general/widgets/overlay_data_sender.dart'; +import 'package:pixel_app_flutter/presentation/screens/general/widgets/suspension_control_button.dart'; import 'package:pixel_app_flutter/presentation/screens/general/widgets/user_defined_buttons_end_drawer.dart'; import 'package:pixel_app_flutter/presentation/screens/general/widgets/wipers_switcher_button.dart'; import 'package:pixel_app_flutter/presentation/widgets/app/organisms/screen_data.dart'; @@ -135,12 +136,20 @@ class HandsetGeneralScreenBody extends StatelessWidget { const LEDSwitcherButton(), const SizedBox(height: 16), const WipersSwitcherButton(), + const SizedBox(height: 16), + const SuspensionControlButton(), ] else ...[ GearWidget(screenSize: size), const SizedBox(height: 16), - const LEDSwitcherButton(), - const SizedBox(height: 16), - const WipersSwitcherButton(), + const Row( + children: [ + LEDSwitcherButton(), + SizedBox(width: 16), + WipersSwitcherButton(), + SizedBox(width: 16), + SuspensionControlButton(), + ], + ), ], ], ); diff --git a/lib/presentation/screens/general/widgets/car_widget.dart b/lib/presentation/screens/general/widgets/car_widget.dart index 049f517..685ee10 100644 --- a/lib/presentation/screens/general/widgets/car_widget.dart +++ b/lib/presentation/screens/general/widgets/car_widget.dart @@ -7,6 +7,7 @@ import 'package:pixel_app_flutter/l10n/l10n.dart'; import 'package:pixel_app_flutter/presentation/app/extensions.dart'; import 'package:pixel_app_flutter/presentation/app/icons.dart'; import 'package:pixel_app_flutter/presentation/screens/general/widgets/led_switcher_button.dart'; +import 'package:pixel_app_flutter/presentation/screens/general/widgets/suspension_control_button.dart'; import 'package:pixel_app_flutter/presentation/screens/general/widgets/wipers_switcher_button.dart'; import 'package:pixel_app_flutter/presentation/widgets/common/atoms/icon_button.dart'; import 'package:pixel_app_flutter/presentation/widgets/common/atoms/relay_widget.dart'; @@ -189,6 +190,12 @@ class _CarWidgetState extends State { left: -140, child: LEDSwitcherButton(), ), + // Suspension + const Positioned( + bottom: 50, + left: -140, + child: SuspensionControlButton(), + ), // Windscreen wipers const Positioned( top: 0, diff --git a/lib/presentation/screens/general/widgets/suspension_control_button.dart b/lib/presentation/screens/general/widgets/suspension_control_button.dart new file mode 100644 index 0000000..c9c0019 --- /dev/null +++ b/lib/presentation/screens/general/widgets/suspension_control_button.dart @@ -0,0 +1,44 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pixel_app_flutter/domain/data_source/data_source.dart'; +import 'package:pixel_app_flutter/l10n/l10n.dart'; +import 'package:pixel_app_flutter/presentation/routes/main_router.dart'; + +class SuspensionControlButton extends StatelessWidget { + const SuspensionControlButton({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SizedBox( + width: 120, + child: ActionChip( + avatar: const Icon( + Icons.unfold_more_sharp, + size: 17, + ), + labelStyle: + const TextStyle(fontFeatures: [FontFeature.tabularFigures()]), + label: Center( + child: Text( + state.payload.when( + low: () => context.l10n.lowSuspensionMode, + highway: () => context.l10n.highwaySuspensionMode, + offRoad: () => context.l10n.offRoadSuspensionMode, + manual: (value) => + context.l10n.manualValueSuspensionMode(value), + ), + textAlign: TextAlign.center, + ), + ), + onPressed: () { + context.router.push(const SuspensionControlDialogRoute()); + }, + ), + ); + }, + ); + } +} diff --git a/lib/presentation/screens/general/widgets/suspension_control_dialog.dart b/lib/presentation/screens/general/widgets/suspension_control_dialog.dart new file mode 100644 index 0000000..f7db50c --- /dev/null +++ b/lib/presentation/screens/general/widgets/suspension_control_dialog.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pixel_app_flutter/domain/data_source/data_source.dart'; +import 'package:pixel_app_flutter/l10n/l10n.dart'; +import 'package:pixel_app_flutter/presentation/app/colors.dart'; + +@RoutePage(name: 'SuspensionControlDialogRoute') +class SuspensionControlDialog extends StatefulWidget { + const SuspensionControlDialog({super.key}); + + @override + State createState() => + _SuspensionControlDialogState(); +} + +class _SuspensionControlDialogState extends State { + late final ManualValueSelectorNotifier manualValueNotifier; + late final StreamSubscription streamSubscription; + + @override + void initState() { + super.initState(); + manualValueNotifier = ManualValueSelectorNotifier( + context.read().state.payload.maybeWhen( + orElse: () => (SuspensionMode.kMaxManualValue / 2).roundToDouble(), + manual: (value) => value.toDouble(), + ), + ); + + streamSubscription = context.read().stream.listen( + (state) { + if (state.isSuccess && + (!state.payload.isManual || manualValueNotifier.valueChanged) && + mounted) { + context.router.maybePop(); + } + }, + ); + } + + @override + void dispose() { + manualValueNotifier.dispose(); + streamSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(context.l10n.suspensionModeDialogTitle), + content: BlocBuilder( + builder: (context, state) { + return AnimatedSize( + alignment: Alignment.topCenter, + duration: const Duration(milliseconds: 300), + child: IgnorePointer( + key: ValueKey(state.payload.isManual), + ignoring: state.isLoading, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final mode in SuspensionMode.values) + RadioListTile( + value: mode.id, + groupValue: state.payload.id, + onChanged: (newMode) { + if (newMode == null) return; + context + .read() + .add(SuspensionControlEvent.switchMode(mode)); + }, + title: Text( + mode.when( + low: () => context.l10n.lowSuspensionMode, + highway: () => context.l10n.highwaySuspensionMode, + offRoad: () => context.l10n.offRoadSuspensionMode, + manual: (_) => context.l10n.manualSuspensionMode, + ), + ), + ), + state.payload.maybeWhen( + orElse: () => const SizedBox.shrink(), + manual: (blocValue) { + return ValueListenableBuilder( + valueListenable: manualValueNotifier, + builder: (context, sliderValue, child) { + final value = + manualValueNotifier.changingManualValue + ? sliderValue + : blocValue.toDouble(); + return Row( + children: [ + Text( + '${value.toInt()}'.padLeft(3, ' '), + style: const TextStyle( + fontFeatures: [ + FontFeature.tabularFigures(), + ], + ), + ), + Expanded( + child: Slider( + value: value, + max: SuspensionMode.kMaxManualValue + .toDouble(), + divisions: SuspensionMode.kMaxManualValue, + onChanged: (value) { + manualValueNotifier.value = value; + }, + onChangeEnd: (value) { + context + .read() + .add( + SuspensionControlEvent.setManual( + value.toInt(), + ), + ); + manualValueNotifier.onChangeEnd(); + }, + ), + ), + ], + ); + }, + ); + }, + ), + state.maybeWhen( + orElse: (payload) => const SizedBox.shrink(), + failure: (payload, error) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + error?.when( + getMode: () => context + .l10n.errorGettingSuspensionModeMessage, + switchMode: (_) => context.l10n + .errorSwitchingSuspensionModeMessage, + getManual: () => context + .l10n.errorGettingManualValueMessage, + setManual: (_) => context + .l10n.errorSettingManualValueMessage, + ) ?? + context + .l10n.errorSwitchingSuspensionModeMessage, + textAlign: TextAlign.center, + style: TextStyle( + color: context.colors.errorPastel, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + if (error == null) return; + context + .read() + .add(error); + }, + child: Text(context.l10n.retryButtonCaption), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +class ManualValueSelectorNotifier extends ValueNotifier { + ManualValueSelectorNotifier(super.value) + : changingManualValue = false, + valueChanged = false; + + bool changingManualValue; + bool valueChanged; + + @override + set value(double v) { + changingManualValue = true; + super.value = v; + } + + void onChangeEnd() { + changingManualValue = false; + valueChanged = true; + } +}