feat: add volume control and merge sound mode feature

- Add volume control screen with sliders for media, ringtone, and alarm
- Update device settings via PUT /devices with CSV (same as language)
- Extract DeviceCsvBuilder to legacy_shared (shared between language and volume)
- Create Riverpod provider for DeviceUpdateDatasource
- Extract VolumeThumbShape to separate widget file
- Merge sound mode feature (SET_SOUND_MODE command, pending backend whitelist)
- Fix sound screen overflow with SingleChildScrollView
This commit is contained in:
2026-03-22 04:01:09 +01:00
parent 33c2403aef
commit c89f1c666e
32 changed files with 879 additions and 119 deletions

View File

@@ -141,6 +141,11 @@ void configureAppRouter() {
name: 'apps_use', name: 'apps_use',
pageBuilder: const AppsUseBuilder().buildPage, pageBuilder: const AppsUseBuilder().buildPage,
), ),
GoRoute(
path: 'volume_control',
name: 'volume_control',
pageBuilder: const VolumeControlBuilder().buildPage,
),
], ],
), ),
], ],

View File

@@ -9,4 +9,5 @@ export 'src/features/locate_device/locate_device_builder.dart';
export 'src/features/health/health_builder.dart'; export 'src/features/health/health_builder.dart';
export 'src/features/rewards/rewards_builder.dart'; export 'src/features/rewards/rewards_builder.dart';
export 'src/features/activity_meter/activity_meter_builder.dart'; export 'src/features/activity_meter/activity_meter_builder.dart';
export 'src/features/apps_use/apps_use_builder.dart'; export 'src/features/apps_use/apps_use_builder.dart';
export 'src/features/volume_control/volume_control_builder.dart';

View File

@@ -68,6 +68,15 @@ class DeviceManagementScreen extends ConsumerWidget {
// text: context.translate(I18n.videoCall), // text: context.translate(I18n.videoCall),
// ), // ),
SizedBox(height: SizeUtils.getByScreen(small: 16, big: 15)), SizedBox(height: SizeUtils.getByScreen(small: 16, big: 15)),
AppMenuButton(
color: theme.getColorFor(ThemeCode.legacyPrimary),
onPressed: () =>
navigationContract.pushTo(AppRoutes.volumeControl),
icon: Icons.volume_up_outlined,
iconSize: SizeUtils.getByScreen(small: 42, big: 40),
text: context.translate(I18n.volumeControl),
),
SizedBox(height: SizeUtils.getByScreen(small: 16, big: 15)),
AppMenuButton( AppMenuButton(
color: theme.getColorFor(ThemeCode.legacyPrimary), color: theme.getColorFor(ThemeCode.legacyPrimary),
onPressed: () => navigationContract.pushTo(AppRoutes.health), onPressed: () => navigationContract.pushTo(AppRoutes.health),

View File

@@ -0,0 +1,27 @@
import 'package:legacy_shared/legacy_shared.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart';
import 'package:sf_shared/sf_shared.dart';
class DeviceUpdateDatasource {
DeviceUpdateDatasource(this._repository);
final QuestiaRepository _repository;
Future<void> updateDeviceSettings({
required DeviceEntity device,
required Map<String, dynamic> updatedSettings,
}) async {
final csvBase64 = DeviceCsvBuilder.buildBase64Csv(
device: device,
settings: updatedSettings,
);
await safeCall(
() => _repository.put<dynamic>(
'/devices',
body: {'csv': csvBase64},
),
'Error updating device settings',
);
}
}

View File

@@ -0,0 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get_it/get_it.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart';
import 'device_update_datasource.dart';
final deviceUpdateDatasourceProvider = Provider<DeviceUpdateDatasource>((ref) {
return DeviceUpdateDatasource(GetIt.I<QuestiaRepository>());
});

View File

@@ -0,0 +1,73 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:legacy_shared/legacy_shared.dart';
import '../../data/device_update_datasource.dart';
import '../../data/device_update_datasource_provider.dart';
import 'volume_control_view_state.dart';
final volumeControlViewModelProvider =
NotifierProvider.autoDispose<VolumeControlViewModel, VolumeControlViewState>(
VolumeControlViewModel.new,
);
class VolumeControlViewModel extends Notifier<VolumeControlViewState> {
late final DeviceUpdateDatasource _datasource;
@override
VolumeControlViewState build() {
_datasource = ref.read(deviceUpdateDatasourceProvider);
Future.microtask(() => _load());
return const VolumeControlViewState();
}
Future<void> _load() async {
try {
final device = ref.read(selectedDeviceProvider);
if (device == null) return;
final volume = device.settings['volume'] as Map<String, dynamic>? ?? {};
state = state.copyWith(
isLoading: false,
device: device,
media: (volume['media'] as num?)?.toInt() ?? 50,
ringtone: (volume['ringtone'] as num?)?.toInt() ?? 50,
alarm: (volume['alarm'] as num?)?.toInt() ?? 50,
);
} catch (e) {
if (!ref.mounted) return;
state = state.copyWith(isLoading: false, errorMessage: e.toString());
}
}
void setMedia(int value) => state = state.copyWith(media: value);
void setRingtone(int value) => state = state.copyWith(ringtone: value);
void setAlarm(int value) => state = state.copyWith(alarm: value);
Future<void> submit() async {
final device = state.device;
if (device == null) return;
try {
state = state.copyWith(isLoading: true, isComplete: false, errorMessage: '');
final settings = Map<String, dynamic>.from(device.settings);
settings['volume'] = {
'media': state.media,
'ringtone': state.ringtone,
'alarm': state.alarm,
};
await _datasource.updateDeviceSettings(
device: device,
updatedSettings: settings,
);
if (!ref.mounted) return;
state = state.copyWith(isLoading: false, isComplete: true);
} catch (e) {
if (!ref.mounted) return;
state = state.copyWith(isLoading: false, errorMessage: e.toString());
}
}
}

View File

@@ -0,0 +1,17 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:sf_shared/sf_shared.dart';
part 'volume_control_view_state.freezed.dart';
@freezed
abstract class VolumeControlViewState with _$VolumeControlViewState {
const factory VolumeControlViewState({
@Default(true) bool isLoading,
@Default(false) bool isComplete,
DeviceEntity? device,
@Default(50) int media,
@Default(50) int ringtone,
@Default(50) int alarm,
@Default('') String errorMessage,
}) = _VolumeControlViewState;
}

View File

@@ -0,0 +1,313 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'volume_control_view_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$VolumeControlViewState {
bool get isLoading; bool get isComplete; DeviceEntity? get device; int get media; int get ringtone; int get alarm; String get errorMessage;
/// Create a copy of VolumeControlViewState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$VolumeControlViewStateCopyWith<VolumeControlViewState> get copyWith => _$VolumeControlViewStateCopyWithImpl<VolumeControlViewState>(this as VolumeControlViewState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is VolumeControlViewState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isComplete, isComplete) || other.isComplete == isComplete)&&(identical(other.device, device) || other.device == device)&&(identical(other.media, media) || other.media == media)&&(identical(other.ringtone, ringtone) || other.ringtone == ringtone)&&(identical(other.alarm, alarm) || other.alarm == alarm)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
}
@override
int get hashCode => Object.hash(runtimeType,isLoading,isComplete,device,media,ringtone,alarm,errorMessage);
@override
String toString() {
return 'VolumeControlViewState(isLoading: $isLoading, isComplete: $isComplete, device: $device, media: $media, ringtone: $ringtone, alarm: $alarm, errorMessage: $errorMessage)';
}
}
/// @nodoc
abstract mixin class $VolumeControlViewStateCopyWith<$Res> {
factory $VolumeControlViewStateCopyWith(VolumeControlViewState value, $Res Function(VolumeControlViewState) _then) = _$VolumeControlViewStateCopyWithImpl;
@useResult
$Res call({
bool isLoading, bool isComplete, DeviceEntity? device, int media, int ringtone, int alarm, String errorMessage
});
$DeviceEntityCopyWith<$Res>? get device;
}
/// @nodoc
class _$VolumeControlViewStateCopyWithImpl<$Res>
implements $VolumeControlViewStateCopyWith<$Res> {
_$VolumeControlViewStateCopyWithImpl(this._self, this._then);
final VolumeControlViewState _self;
final $Res Function(VolumeControlViewState) _then;
/// Create a copy of VolumeControlViewState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isLoading = null,Object? isComplete = null,Object? device = freezed,Object? media = null,Object? ringtone = null,Object? alarm = null,Object? errorMessage = null,}) {
return _then(_self.copyWith(
isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,isComplete: null == isComplete ? _self.isComplete : isComplete // ignore: cast_nullable_to_non_nullable
as bool,device: freezed == device ? _self.device : device // ignore: cast_nullable_to_non_nullable
as DeviceEntity?,media: null == media ? _self.media : media // ignore: cast_nullable_to_non_nullable
as int,ringtone: null == ringtone ? _self.ringtone : ringtone // ignore: cast_nullable_to_non_nullable
as int,alarm: null == alarm ? _self.alarm : alarm // ignore: cast_nullable_to_non_nullable
as int,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,
));
}
/// Create a copy of VolumeControlViewState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$DeviceEntityCopyWith<$Res>? get device {
if (_self.device == null) {
return null;
}
return $DeviceEntityCopyWith<$Res>(_self.device!, (value) {
return _then(_self.copyWith(device: value));
});
}
}
/// Adds pattern-matching-related methods to [VolumeControlViewState].
extension VolumeControlViewStatePatterns on VolumeControlViewState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _VolumeControlViewState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _VolumeControlViewState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _VolumeControlViewState value) $default,){
final _that = this;
switch (_that) {
case _VolumeControlViewState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _VolumeControlViewState value)? $default,){
final _that = this;
switch (_that) {
case _VolumeControlViewState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isLoading, bool isComplete, DeviceEntity? device, int media, int ringtone, int alarm, String errorMessage)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _VolumeControlViewState() when $default != null:
return $default(_that.isLoading,_that.isComplete,_that.device,_that.media,_that.ringtone,_that.alarm,_that.errorMessage);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isLoading, bool isComplete, DeviceEntity? device, int media, int ringtone, int alarm, String errorMessage) $default,) {final _that = this;
switch (_that) {
case _VolumeControlViewState():
return $default(_that.isLoading,_that.isComplete,_that.device,_that.media,_that.ringtone,_that.alarm,_that.errorMessage);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isLoading, bool isComplete, DeviceEntity? device, int media, int ringtone, int alarm, String errorMessage)? $default,) {final _that = this;
switch (_that) {
case _VolumeControlViewState() when $default != null:
return $default(_that.isLoading,_that.isComplete,_that.device,_that.media,_that.ringtone,_that.alarm,_that.errorMessage);case _:
return null;
}
}
}
/// @nodoc
class _VolumeControlViewState implements VolumeControlViewState {
const _VolumeControlViewState({this.isLoading = true, this.isComplete = false, this.device, this.media = 50, this.ringtone = 50, this.alarm = 50, this.errorMessage = ''});
@override@JsonKey() final bool isLoading;
@override@JsonKey() final bool isComplete;
@override final DeviceEntity? device;
@override@JsonKey() final int media;
@override@JsonKey() final int ringtone;
@override@JsonKey() final int alarm;
@override@JsonKey() final String errorMessage;
/// Create a copy of VolumeControlViewState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$VolumeControlViewStateCopyWith<_VolumeControlViewState> get copyWith => __$VolumeControlViewStateCopyWithImpl<_VolumeControlViewState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _VolumeControlViewState&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isComplete, isComplete) || other.isComplete == isComplete)&&(identical(other.device, device) || other.device == device)&&(identical(other.media, media) || other.media == media)&&(identical(other.ringtone, ringtone) || other.ringtone == ringtone)&&(identical(other.alarm, alarm) || other.alarm == alarm)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
}
@override
int get hashCode => Object.hash(runtimeType,isLoading,isComplete,device,media,ringtone,alarm,errorMessage);
@override
String toString() {
return 'VolumeControlViewState(isLoading: $isLoading, isComplete: $isComplete, device: $device, media: $media, ringtone: $ringtone, alarm: $alarm, errorMessage: $errorMessage)';
}
}
/// @nodoc
abstract mixin class _$VolumeControlViewStateCopyWith<$Res> implements $VolumeControlViewStateCopyWith<$Res> {
factory _$VolumeControlViewStateCopyWith(_VolumeControlViewState value, $Res Function(_VolumeControlViewState) _then) = __$VolumeControlViewStateCopyWithImpl;
@override @useResult
$Res call({
bool isLoading, bool isComplete, DeviceEntity? device, int media, int ringtone, int alarm, String errorMessage
});
@override $DeviceEntityCopyWith<$Res>? get device;
}
/// @nodoc
class __$VolumeControlViewStateCopyWithImpl<$Res>
implements _$VolumeControlViewStateCopyWith<$Res> {
__$VolumeControlViewStateCopyWithImpl(this._self, this._then);
final _VolumeControlViewState _self;
final $Res Function(_VolumeControlViewState) _then;
/// Create a copy of VolumeControlViewState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isLoading = null,Object? isComplete = null,Object? device = freezed,Object? media = null,Object? ringtone = null,Object? alarm = null,Object? errorMessage = null,}) {
return _then(_VolumeControlViewState(
isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,isComplete: null == isComplete ? _self.isComplete : isComplete // ignore: cast_nullable_to_non_nullable
as bool,device: freezed == device ? _self.device : device // ignore: cast_nullable_to_non_nullable
as DeviceEntity?,media: null == media ? _self.media : media // ignore: cast_nullable_to_non_nullable
as int,ringtone: null == ringtone ? _self.ringtone : ringtone // ignore: cast_nullable_to_non_nullable
as int,alarm: null == alarm ? _self.alarm : alarm // ignore: cast_nullable_to_non_nullable
as int,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String,
));
}
/// Create a copy of VolumeControlViewState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$DeviceEntityCopyWith<$Res>? get device {
if (_self.device == null) {
return null;
}
return $DeviceEntityCopyWith<$Res>(_self.device!, (value) {
return _then(_self.copyWith(device: value));
});
}
}
// dart format on

View File

@@ -0,0 +1,167 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:legacy_shared/legacy_shared.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'state/volume_control_view_model.dart';
import 'widgets/volume_thumb_shape.dart';
class VolumeControlScreen extends ConsumerWidget {
const VolumeControlScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.watch(themePortProvider);
final vm = ref.read(volumeControlViewModelProvider.notifier);
final state = ref.watch(volumeControlViewModelProvider);
ref.listen(volumeControlViewModelProvider.select((s) => s.errorMessage),
(_, msg) {
if (msg.isNotEmpty) {
showTopSnackbar(context, message: msg, type: MessageType.error);
}
});
ref.listen(volumeControlViewModelProvider.select((s) => s.isComplete),
(_, done) {
if (done) Navigator.pop(context);
});
final primaryColor = theme.getColorFor(ThemeCode.legacyPrimary);
return LegacyPageLayout(
theme: theme,
title: context.translate(I18n.volumeControl),
body: state.isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_VolumeCard(
label: context.translate(I18n.volumeMedia),
value: state.media,
color: primaryColor,
onChanged: vm.setMedia,
),
const SizedBox(height: 12),
_VolumeCard(
label: context.translate(I18n.volumeRingtone),
value: state.ringtone,
color: primaryColor,
onChanged: vm.setRingtone,
),
const SizedBox(height: 12),
_VolumeCard(
label: context.translate(I18n.volumeAlarm),
value: state.alarm,
color: primaryColor,
onChanged: vm.setAlarm,
),
const SizedBox(height: 20),
Text(
context.translate(I18n.volumeHint),
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade500,
height: 1.4,
),
),
],
),
),
footer: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
child: state.isLoading
? const Center(child: CircularProgressIndicator())
: PrimaryButton(
onPressed: vm.submit,
text: context.translate(I18n.volumeSend),
color: primaryColor,
),
),
);
}
}
class _VolumeCard extends StatelessWidget {
final String label;
final int value;
final Color color;
final ValueChanged<int> onChanged;
const _VolumeCard({
required this.label,
required this.value,
required this.color,
required this.onChanged,
});
int get _displayValue => (value / 10).round();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.volume_up, color: Colors.grey.shade400, size: 22),
Expanded(
child: SliderTheme(
data: SliderThemeData(
activeTrackColor: color,
thumbColor: Colors.white,
thumbShape: VolumeThumbShape(color: color),
inactiveTrackColor: Colors.grey.shade200,
overlayColor: color.withValues(alpha: 0.1),
trackHeight: 6,
),
child: Slider(
value: value.toDouble(),
min: 0,
max: 100,
divisions: 10,
onChanged: (v) => onChanged(v.round()),
),
),
),
SizedBox(
width: 24,
child: Text(
'$_displayValue',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
class VolumeThumbShape extends SliderComponentShape {
final Color color;
static const _radius = 11.0;
static const _strokeWidth = 2.5;
static const _lineSpacing = 2.5;
static const _lineLength = 4.0;
const VolumeThumbShape({required this.color});
@override
Size getPreferredSize(bool isEnabled, bool isDiscrete) =>
const Size(_radius * 2, _radius * 2);
@override
void paint(
PaintingContext context,
Offset center, {
required Animation<double> activationAnimation,
required Animation<double> enableAnimation,
required bool isDiscrete,
required TextPainter labelPainter,
required RenderBox parentBox,
required SliderThemeData sliderTheme,
required TextDirection textDirection,
required double value,
required double textScaleFactor,
required Size sizeWithOverflow,
}) {
final canvas = context.canvas;
canvas.drawCircle(
center,
_radius,
Paint()
..color = Colors.white
..style = PaintingStyle.fill,
);
canvas.drawCircle(
center,
_radius,
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = _strokeWidth,
);
final linePaint = Paint()
..color = color
..strokeWidth = 2
..strokeCap = StrokeCap.round;
canvas.drawLine(
Offset(center.dx - _lineSpacing, center.dy - _lineLength),
Offset(center.dx - _lineSpacing, center.dy + _lineLength),
linePaint,
);
canvas.drawLine(
Offset(center.dx + _lineSpacing, center.dy - _lineLength),
Offset(center.dx + _lineSpacing, center.dy + _lineLength),
linePaint,
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'presentation/volume_control_screen.dart';
class VolumeControlBuilder {
const VolumeControlBuilder();
Page<void> buildPage(BuildContext context, GoRouterState state) {
return MaterialPage<void>(
key: state.pageKey,
child: const VolumeControlScreen(),
);
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:legacy_shared/legacy_shared.dart'; import 'package:legacy_shared/legacy_shared.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart'; import 'package:sf_infrastructure/sf_infrastructure.dart';
import 'package:sf_shared/sf_shared.dart'; import 'package:sf_shared/sf_shared.dart';
@@ -19,38 +17,10 @@ class LanguageRemoteDatasourceImpl implements LanguageRemoteDatasource {
final settings = Map<String, dynamic>.from(device.settings); final settings = Map<String, dynamic>.from(device.settings);
settings['language'] = newLanguage; settings['language'] = newLanguage;
String csvEscape(dynamic value) { final csvBase64 = DeviceCsvBuilder.buildBase64Csv(
if (value == null) return ''; device: device,
final json = jsonEncode(value); settings: settings,
return '"${json.replaceAll('"', '""')}"'; );
}
final csvHeader =
'id,carrierName,flags,settings,battery,carrierBirthday,'
'carrierWeight,carrierStepLength,carrierGenre,comment,'
'groupId,lastConnection,paymentOptions,phone,simId,tags';
final csvRow = [
device.id,
device.carrierName ?? '',
csvEscape(device.flags),
csvEscape(settings),
device.battery ?? '',
device.carrierBirthday ?? '',
device.carrierWeight ?? '',
device.carrierStepLength ?? '',
device.carrierGenre ?? '',
device.comment ?? '',
device.groupId ?? '',
device.lastConnection ?? '',
device.paymentOptions != null ? csvEscape(device.paymentOptions) : '',
device.phone ?? '',
device.simId ?? '',
csvEscape(device.tags ?? []),
].join(',');
final csv = '$csvHeader\n$csvRow';
final csvBase64 = base64Encode(utf8.encode(csv));
await safeCall( await safeCall(
() => _repository.put<dynamic>( () => _repository.put<dynamic>(

View File

@@ -94,13 +94,13 @@ class SettingsScreen extends ConsumerWidget {
text: I18n.sosContacts, text: I18n.sosContacts,
color: color, color: color,
), ),
// _item( _item(
// context, context,
// onPressed: () => navigationContract.pushTo(AppRoutes.sound), onPressed: () => navigationContract.pushTo(AppRoutes.sound),
// icon: Icons.volume_up_outlined, icon: Icons.volume_up_outlined,
// text: I18n.sound, text: I18n.sound,
// color: color, color: color,
// ), ),
// _item( // _item(
// context, // context,
// onPressed: () => // onPressed: () =>

View File

@@ -1,3 +0,0 @@
abstract class SetSoundUseCase {
Future<void> setSound({required String deviceId});
}

View File

@@ -1,16 +0,0 @@
import 'package:settings/src/core/domain/repositories/settings_repository.dart';
import 'set_sound_use_case.dart';
class SetSoundUseCaseImpl implements SetSoundUseCase {
SetSoundUseCaseImpl(this._repository);
final SettingsRepository _repository;
@override
Future<void> setSound({required String deviceId}) async {
return;
// return _repository.setSound(deviceId: deviceId);
}
}

View File

@@ -1,10 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:settings/src/core/providers/settings_repository_provider.dart';
import '../../domain/set_sound_use_case.dart';
import '../../domain/set_sound_use_case_impl.dart';
final setSoundUseCaseProvider = Provider.autoDispose<SetSoundUseCase>((ref) {
final settingsRepository = ref.read(settingsRepositoryProvider);
return SetSoundUseCaseImpl(settingsRepository);
});

View File

@@ -20,11 +20,31 @@ class SoundScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.read(themePortProvider); final theme = ref.read(themePortProvider);
ref.listen(soundViewModelProvider.select((s) => s.errorMessage), (
_,
errorMessage,
) {
if (errorMessage.isNotEmpty) {
showTopSnackbar(
context,
message: errorMessage,
type: MessageType.error,
);
}
});
ref.listen(soundViewModelProvider.select((s) => s.isComplete), (
_,
isComplete,
) {
if (isComplete) Navigator.pop(context);
});
return LegacyPageLayout( return LegacyPageLayout(
theme: theme, theme: theme,
title: context.translate(I18n.sound), title: context.translate(I18n.sound),
body: Padding( body: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 18, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
child: Column( child: Column(
children: [ children: [
Center(child: Center(child:
@@ -33,7 +53,7 @@ class SoundScreen extends ConsumerWidget {
size: 180, size: 180,
) )
), ),
SizedBox(height: 36), const SizedBox(height: 36),
const _OptionsSection() const _OptionsSection()
], ],
), ),
@@ -61,15 +81,15 @@ class _OptionsSection extends ConsumerWidget {
_SectionButton( _SectionButton(
title: context.translate(I18n.soundAndVibration), title: context.translate(I18n.soundAndVibration),
icon: Icons.volume_up_outlined, icon: Icons.volume_up_outlined,
active: soundOption == 'SOUND_AND_VIBRATION', active: soundOption == 'VIBRATION_AND_RINGING',
onPressed: () {vm.setSoundOption('SOUND_AND_VIBRATION');}, onPressed: () {vm.setSoundOption('VIBRATION_AND_RINGING');},
), ),
SizedBox(height: 12), SizedBox(height: 12),
_SectionButton( _SectionButton(
title: context.translate(I18n.soundOnly), title: context.translate(I18n.soundOnly),
icon: Icons.volume_up_outlined, icon: Icons.volume_up_outlined,
active: soundOption == 'SOUND', active: soundOption == 'RINGING',
onPressed: () {vm.setSoundOption('SOUND');}, onPressed: () {vm.setSoundOption('RINGING');},
), ),
SizedBox(height: 12), SizedBox(height: 12),
_SectionButton( _SectionButton(
@@ -82,8 +102,8 @@ class _OptionsSection extends ConsumerWidget {
_SectionButton( _SectionButton(
title: context.translate(I18n.silent), title: context.translate(I18n.silent),
icon: Icons.volume_mute_outlined, icon: Icons.volume_mute_outlined,
active: soundOption == 'SILENT', active: soundOption == 'SILENCE',
onPressed: () {vm.setSoundOption('SILENT');}, onPressed: () {vm.setSoundOption('SILENCE');},
), ),
] ]
), ),
@@ -152,7 +172,7 @@ class _SaveSection extends ConsumerWidget {
return Padding( return Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: PrimaryButton( child: PrimaryButton(
onPressed: vm.submit, onPressed: () {vm.submit();},
text: context.translate(I18n.save), text: context.translate(I18n.save),
color: theme.getColorFor(ThemeCode.legacyPrimary) color: theme.getColorFor(ThemeCode.legacyPrimary)
), ),

View File

@@ -1,9 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:legacy_shared/legacy_shared.dart'; import 'package:legacy_shared/legacy_shared.dart';
import 'package:sf_shared/sf_shared.dart';
import '../../domain/set_sound_use_case.dart';
import '../providers/set_sound_use_case_provider.dart';
import 'sound_view_state.dart'; import 'sound_view_state.dart';
final soundViewModelProvider = final soundViewModelProvider =
@@ -12,11 +9,11 @@ NotifierProvider.autoDispose<SoundViewModel, SoundViewState>(
); );
class SoundViewModel extends Notifier<SoundViewState> { class SoundViewModel extends Notifier<SoundViewState> {
late final SetSoundUseCase _setSoundUseCase; late final CommandsRepository _commandsRepository;
@override @override
SoundViewState build() { SoundViewState build() {
_setSoundUseCase = ref.read(setSoundUseCaseProvider); _commandsRepository = ref.read(commandsRepositoryProvider);
Future.microtask(() => load()); Future.microtask(() => load());
@@ -25,16 +22,12 @@ class SoundViewModel extends Notifier<SoundViewState> {
Future<void> load() async { Future<void> load() async {
final device = ref.read(selectedDeviceProvider); final device = ref.read(selectedDeviceProvider);
setDevice(device!); if (device == null) return;
state = state.copyWith(
soundOption: 'SOUND_AND_VIBRATION',
isLoading: false,
);
}
void setDevice(DeviceEntity device) {
state = state.copyWith( state = state.copyWith(
deviceId: device.identificator, deviceId: device.identificator,
soundOption: device.settings['soundMode'] ?? 'VIBRATION',
isLoading: false,
); );
} }
@@ -50,8 +43,20 @@ class SoundViewModel extends Notifier<SoundViewState> {
try { try {
state = state.copyWith( state = state.copyWith(
isLoading: true, isLoading: true,
isComplete: false,
);
final request = SendCommandRequestModel(
device: state.deviceId,
command: DeviceCommand.setSoundMode,
data: {'soundMode': state.soundOption}
);
await _commandsRepository.send(request: request);
state = state.copyWith(
isLoading: false,
isComplete: true,
); );
_setSoundUseCase.setSound(deviceId: state.deviceId);
} catch (e) { } catch (e) {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,

View File

@@ -9,6 +9,7 @@ abstract class SoundViewState with _$SoundViewState {
@Default('') String deviceId, @Default('') String deviceId,
String? soundOption, String? soundOption,
@Default(true) bool isLoading, @Default(true) bool isLoading,
@Default(false) bool isComplete,
@Default('') String errorMessage, @Default('') String errorMessage,
}) = _SoundViewState; }) = _SoundViewState;
} }

View File

@@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$SoundViewState { mixin _$SoundViewState {
String get deviceId; String? get soundOption; bool get isLoading; String get errorMessage; String get deviceId; String? get soundOption; bool get isLoading; bool get isComplete; String get errorMessage;
/// Create a copy of SoundViewState /// Create a copy of SoundViewState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -25,16 +25,16 @@ $SoundViewStateCopyWith<SoundViewState> get copyWith => _$SoundViewStateCopyWith
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SoundViewState&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.soundOption, soundOption) || other.soundOption == soundOption)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)); return identical(this, other) || (other.runtimeType == runtimeType&&other is SoundViewState&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.soundOption, soundOption) || other.soundOption == soundOption)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isComplete, isComplete) || other.isComplete == isComplete)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
} }
@override @override
int get hashCode => Object.hash(runtimeType,deviceId,soundOption,isLoading,errorMessage); int get hashCode => Object.hash(runtimeType,deviceId,soundOption,isLoading,isComplete,errorMessage);
@override @override
String toString() { String toString() {
return 'SoundViewState(deviceId: $deviceId, soundOption: $soundOption, isLoading: $isLoading, errorMessage: $errorMessage)'; return 'SoundViewState(deviceId: $deviceId, soundOption: $soundOption, isLoading: $isLoading, isComplete: $isComplete, errorMessage: $errorMessage)';
} }
@@ -45,7 +45,7 @@ abstract mixin class $SoundViewStateCopyWith<$Res> {
factory $SoundViewStateCopyWith(SoundViewState value, $Res Function(SoundViewState) _then) = _$SoundViewStateCopyWithImpl; factory $SoundViewStateCopyWith(SoundViewState value, $Res Function(SoundViewState) _then) = _$SoundViewStateCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String deviceId, String? soundOption, bool isLoading, String errorMessage String deviceId, String? soundOption, bool isLoading, bool isComplete, String errorMessage
}); });
@@ -62,11 +62,12 @@ class _$SoundViewStateCopyWithImpl<$Res>
/// Create a copy of SoundViewState /// Create a copy of SoundViewState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? deviceId = null,Object? soundOption = freezed,Object? isLoading = null,Object? errorMessage = null,}) { @pragma('vm:prefer-inline') @override $Res call({Object? deviceId = null,Object? soundOption = freezed,Object? isLoading = null,Object? isComplete = null,Object? errorMessage = null,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
as String,soundOption: freezed == soundOption ? _self.soundOption : soundOption // ignore: cast_nullable_to_non_nullable as String,soundOption: freezed == soundOption ? _self.soundOption : soundOption // ignore: cast_nullable_to_non_nullable
as String?,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable as String?,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,isComplete: null == isComplete ? _self.isComplete : isComplete // ignore: cast_nullable_to_non_nullable
as bool,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable as bool,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String, as String,
)); ));
@@ -153,10 +154,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String deviceId, String? soundOption, bool isLoading, String errorMessage)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String deviceId, String? soundOption, bool isLoading, bool isComplete, String errorMessage)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _SoundViewState() when $default != null: case _SoundViewState() when $default != null:
return $default(_that.deviceId,_that.soundOption,_that.isLoading,_that.errorMessage);case _: return $default(_that.deviceId,_that.soundOption,_that.isLoading,_that.isComplete,_that.errorMessage);case _:
return orElse(); return orElse();
} }
@@ -174,10 +175,10 @@ return $default(_that.deviceId,_that.soundOption,_that.isLoading,_that.errorMess
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String deviceId, String? soundOption, bool isLoading, String errorMessage) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String deviceId, String? soundOption, bool isLoading, bool isComplete, String errorMessage) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _SoundViewState(): case _SoundViewState():
return $default(_that.deviceId,_that.soundOption,_that.isLoading,_that.errorMessage);case _: return $default(_that.deviceId,_that.soundOption,_that.isLoading,_that.isComplete,_that.errorMessage);case _:
throw StateError('Unexpected subclass'); throw StateError('Unexpected subclass');
} }
@@ -194,10 +195,10 @@ return $default(_that.deviceId,_that.soundOption,_that.isLoading,_that.errorMess
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String deviceId, String? soundOption, bool isLoading, String errorMessage)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String deviceId, String? soundOption, bool isLoading, bool isComplete, String errorMessage)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _SoundViewState() when $default != null: case _SoundViewState() when $default != null:
return $default(_that.deviceId,_that.soundOption,_that.isLoading,_that.errorMessage);case _: return $default(_that.deviceId,_that.soundOption,_that.isLoading,_that.isComplete,_that.errorMessage);case _:
return null; return null;
} }
@@ -209,12 +210,13 @@ return $default(_that.deviceId,_that.soundOption,_that.isLoading,_that.errorMess
class _SoundViewState implements SoundViewState { class _SoundViewState implements SoundViewState {
const _SoundViewState({this.deviceId = '', this.soundOption, this.isLoading = true, this.errorMessage = ''}); const _SoundViewState({this.deviceId = '', this.soundOption, this.isLoading = true, this.isComplete = false, this.errorMessage = ''});
@override@JsonKey() final String deviceId; @override@JsonKey() final String deviceId;
@override final String? soundOption; @override final String? soundOption;
@override@JsonKey() final bool isLoading; @override@JsonKey() final bool isLoading;
@override@JsonKey() final bool isComplete;
@override@JsonKey() final String errorMessage; @override@JsonKey() final String errorMessage;
/// Create a copy of SoundViewState /// Create a copy of SoundViewState
@@ -227,16 +229,16 @@ _$SoundViewStateCopyWith<_SoundViewState> get copyWith => __$SoundViewStateCopyW
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SoundViewState&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.soundOption, soundOption) || other.soundOption == soundOption)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _SoundViewState&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.soundOption, soundOption) || other.soundOption == soundOption)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isComplete, isComplete) || other.isComplete == isComplete)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage));
} }
@override @override
int get hashCode => Object.hash(runtimeType,deviceId,soundOption,isLoading,errorMessage); int get hashCode => Object.hash(runtimeType,deviceId,soundOption,isLoading,isComplete,errorMessage);
@override @override
String toString() { String toString() {
return 'SoundViewState(deviceId: $deviceId, soundOption: $soundOption, isLoading: $isLoading, errorMessage: $errorMessage)'; return 'SoundViewState(deviceId: $deviceId, soundOption: $soundOption, isLoading: $isLoading, isComplete: $isComplete, errorMessage: $errorMessage)';
} }
@@ -247,7 +249,7 @@ abstract mixin class _$SoundViewStateCopyWith<$Res> implements $SoundViewStateCo
factory _$SoundViewStateCopyWith(_SoundViewState value, $Res Function(_SoundViewState) _then) = __$SoundViewStateCopyWithImpl; factory _$SoundViewStateCopyWith(_SoundViewState value, $Res Function(_SoundViewState) _then) = __$SoundViewStateCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String deviceId, String? soundOption, bool isLoading, String errorMessage String deviceId, String? soundOption, bool isLoading, bool isComplete, String errorMessage
}); });
@@ -264,11 +266,12 @@ class __$SoundViewStateCopyWithImpl<$Res>
/// Create a copy of SoundViewState /// Create a copy of SoundViewState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? deviceId = null,Object? soundOption = freezed,Object? isLoading = null,Object? errorMessage = null,}) { @override @pragma('vm:prefer-inline') $Res call({Object? deviceId = null,Object? soundOption = freezed,Object? isLoading = null,Object? isComplete = null,Object? errorMessage = null,}) {
return _then(_SoundViewState( return _then(_SoundViewState(
deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
as String,soundOption: freezed == soundOption ? _self.soundOption : soundOption // ignore: cast_nullable_to_non_nullable as String,soundOption: freezed == soundOption ? _self.soundOption : soundOption // ignore: cast_nullable_to_non_nullable
as String?,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable as String?,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,isComplete: null == isComplete ? _self.isComplete : isComplete // ignore: cast_nullable_to_non_nullable
as bool,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable as bool,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String, as String,
)); ));

View File

@@ -11,6 +11,7 @@ export 'src/data/models/device_response_model.dart';
export 'src/data/models/get_devices_response_model.dart'; export 'src/data/models/get_devices_response_model.dart';
export 'src/data/models/send_command_request_model.dart'; export 'src/data/models/send_command_request_model.dart';
export 'src/utils/dio_error_mapper.dart'; export 'src/utils/dio_error_mapper.dart';
export 'src/utils/device_csv_builder.dart';
export 'src/domain/repositories/command_repository.dart'; export 'src/domain/repositories/command_repository.dart';
export 'src/providers/commands_repository_provider.dart'; export 'src/providers/commands_repository_provider.dart';
export 'src/domain/repositories/devices_repository.dart'; export 'src/domain/repositories/devices_repository.dart';

View File

@@ -16,8 +16,8 @@ enum DeviceCommand {
setLanguage, setLanguage,
@JsonValue('SHUTDOWN') @JsonValue('SHUTDOWN')
shutdown, shutdown,
@JsonValue('SOUND') @JsonValue('SET_SOUND_MODE')
sound, setSoundMode,
} }
@freezed @freezed

View File

@@ -29,5 +29,5 @@ const _$DeviceCommandEnumMap = {
DeviceCommand.rewards: 'REWARDS', DeviceCommand.rewards: 'REWARDS',
DeviceCommand.setLanguage: 'SET_LANGUAGE', DeviceCommand.setLanguage: 'SET_LANGUAGE',
DeviceCommand.shutdown: 'SHUTDOWN', DeviceCommand.shutdown: 'SHUTDOWN',
DeviceCommand.sound: 'SOUND', DeviceCommand.setSoundMode: 'SET_SOUND_MODE',
}; };

View File

@@ -0,0 +1,45 @@
import 'dart:convert';
import 'package:sf_shared/sf_shared.dart';
class DeviceCsvBuilder {
const DeviceCsvBuilder._();
static String buildBase64Csv({
required DeviceEntity device,
required Map<String, dynamic> settings,
}) {
final csvHeader =
'id,carrierName,flags,settings,battery,carrierBirthday,'
'carrierWeight,carrierStepLength,carrierGenre,comment,'
'groupId,lastConnection,paymentOptions,phone,simId,tags';
final csvRow = [
device.id,
device.carrierName ?? '',
_csvEscape(device.flags),
_csvEscape(settings),
device.battery ?? '',
device.carrierBirthday ?? '',
device.carrierWeight ?? '',
device.carrierStepLength ?? '',
device.carrierGenre ?? '',
device.comment ?? '',
device.groupId ?? '',
device.lastConnection ?? '',
device.paymentOptions != null ? _csvEscape(device.paymentOptions) : '',
device.phone ?? '',
device.simId ?? '',
_csvEscape(device.tags ?? []),
].join(',');
final csv = '$csvHeader\n$csvRow';
return base64Encode(utf8.encode(csv));
}
static String _csvEscape(dynamic value) {
if (value == null) return '';
final json = jsonEncode(value);
return '"${json.replaceAll('"', '""')}"';
}
}

View File

@@ -63,6 +63,7 @@ class AppRoutes {
static const rewards = '$deviceManagement/rewards'; static const rewards = '$deviceManagement/rewards';
static const activityMeter = '$deviceManagement/activity_meter'; static const activityMeter = '$deviceManagement/activity_meter';
static const appsUse = '$deviceManagement/apps_use'; static const appsUse = '$deviceManagement/apps_use';
static const volumeControl = '$deviceManagement/volume_control';
static const legacyLogin = '$legacy/login'; static const legacyLogin = '$legacy/login';
static const legacySignup = '$legacy/signup'; static const legacySignup = '$legacy/signup';

View File

@@ -617,5 +617,11 @@
"vibrationOnly": "Nur Vibration", "vibrationOnly": "Nur Vibration",
"silent": "Lautlos", "silent": "Lautlos",
"syncClockMessage": "Synchronisieren Sie die Geräteuhr mit der aktuellen Uhrzeit", "syncClockMessage": "Synchronisieren Sie die Geräteuhr mit der aktuellen Uhrzeit",
"locationWifiNetworksOptional": "WLAN-Netzwerke (optional)" "locationWifiNetworksOptional": "WLAN-Netzwerke (optional)",
"volumeControl": "Lautstärkeregelung",
"volumeMedia": "Medienlautstärke",
"volumeRingtone": "Klingeltonlautstärke",
"volumeAlarm": "Alarmlautstärke",
"volumeHint": "Sie können den Schieberegler ziehen, um die Gerätelautstärke anzupassen. Die App speichert nur die zuletzt erfolgreich eingestellte Lautstärke. Die tatsächliche Lautstärke hängt vom Gerät ab.",
"volumeSend": "Senden"
} }

View File

@@ -605,6 +605,12 @@
"smsAlert": "SMS Alerts", "smsAlert": "SMS Alerts",
"sosContacts": "SOS Contacts", "sosContacts": "SOS Contacts",
"sound": "Sounds", "sound": "Sounds",
"volumeControl": "Volume control",
"volumeMedia": "Media volume",
"volumeRingtone": "Ringtone volume",
"volumeAlarm": "Alarm volume",
"volumeHint": "You can drag the slider to adjust the device volume. The app only saves the most recently successfully adjusted volume level in cache. The actual volume will depend on the device.",
"volumeSend": "Send",
"syncClock": "Time Sync", "syncClock": "Time Sync",
"timezone": "Timezone", "timezone": "Timezone",
"wifiSettings": "WiFi Settings", "wifiSettings": "WiFi Settings",

View File

@@ -603,6 +603,12 @@
"smsAlert": "Alertas SMS", "smsAlert": "Alertas SMS",
"sosContacts": "Agenda SOS", "sosContacts": "Agenda SOS",
"sound": "Sonidos", "sound": "Sonidos",
"volumeControl": "Control de volumen",
"volumeMedia": "Volumen de medios",
"volumeRingtone": "Volumen de tono de llamada",
"volumeAlarm": "Volumen de alarma",
"volumeHint": "Puede arrastrar el control deslizante para ajustar el volumen del dispositivo. La aplicación solo guarda el nivel de volumen más recientemente ajustado con éxito en la memoria caché. El volumen real dependerá del dispositivo.",
"volumeSend": "Enviar",
"syncClock": "Sincronización de tiempo", "syncClock": "Sincronización de tiempo",
"timezone": "Cambio de horario y zona", "timezone": "Cambio de horario y zona",
"wifiSettings": "Configuración WiFi", "wifiSettings": "Configuración WiFi",

View File

@@ -617,5 +617,11 @@
"vibrationOnly": "Vibration uniquement", "vibrationOnly": "Vibration uniquement",
"silent": "Silencieux", "silent": "Silencieux",
"syncClockMessage": "Synchroniser l'horloge de l'appareil avec l'heure actuelle", "syncClockMessage": "Synchroniser l'horloge de l'appareil avec l'heure actuelle",
"locationWifiNetworksOptional": "Réseaux WiFi (facultatif)" "locationWifiNetworksOptional": "Réseaux WiFi (facultatif)",
"volumeControl": "Contrôle du volume",
"volumeMedia": "Volume des médias",
"volumeRingtone": "Volume de la sonnerie",
"volumeAlarm": "Volume de l'alarme",
"volumeHint": "Vous pouvez faire glisser le curseur pour régler le volume de l'appareil. L'application ne sauvegarde que le dernier niveau de volume ajusté avec succès. Le volume réel dépendra de l'appareil.",
"volumeSend": "Envoyer"
} }

View File

@@ -617,5 +617,11 @@
"vibrationOnly": "Solo vibrazione", "vibrationOnly": "Solo vibrazione",
"silent": "Silenzioso", "silent": "Silenzioso",
"syncClockMessage": "Sincronizza l'orologio del dispositivo con l'ora attuale", "syncClockMessage": "Sincronizza l'orologio del dispositivo con l'ora attuale",
"locationWifiNetworksOptional": "Reti WiFi (facoltativo)" "locationWifiNetworksOptional": "Reti WiFi (facoltativo)",
"volumeControl": "Controllo volume",
"volumeMedia": "Volume multimediale",
"volumeRingtone": "Volume suoneria",
"volumeAlarm": "Volume sveglia",
"volumeHint": "Puoi trascinare il cursore per regolare il volume del dispositivo. L'app salva solo l'ultimo livello di volume regolato con successo. Il volume effettivo dipenderà dal dispositivo.",
"volumeSend": "Invia"
} }

View File

@@ -617,5 +617,11 @@
"vibrationOnly": "Apenas vibração", "vibrationOnly": "Apenas vibração",
"silent": "Silencioso", "silent": "Silencioso",
"syncClockMessage": "Sincronize o relógio do dispositivo com a hora atual", "syncClockMessage": "Sincronize o relógio do dispositivo com a hora atual",
"locationWifiNetworksOptional": "Redes WiFi (opcional)" "locationWifiNetworksOptional": "Redes WiFi (opcional)",
"volumeControl": "Controle de volume",
"volumeMedia": "Volume de mídia",
"volumeRingtone": "Volume do toque",
"volumeAlarm": "Volume do alarme",
"volumeHint": "Você pode arrastar o controle deslizante para ajustar o volume do dispositivo. O aplicativo salva apenas o nível de volume ajustado com sucesso mais recentemente. O volume real dependerá do dispositivo.",
"volumeSend": "Enviar"
} }

View File

@@ -646,6 +646,12 @@ class I18n {
static const String sound = 'sound'; static const String sound = 'sound';
static const String soundAndVibration = 'soundAndVibration'; static const String soundAndVibration = 'soundAndVibration';
static const String soundOnly = 'soundOnly'; static const String soundOnly = 'soundOnly';
static const String volumeControl = 'volumeControl';
static const String volumeMedia = 'volumeMedia';
static const String volumeRingtone = 'volumeRingtone';
static const String volumeAlarm = 'volumeAlarm';
static const String volumeHint = 'volumeHint';
static const String volumeSend = 'volumeSend';
static const String spo2 = 'spo2'; static const String spo2 = 'spo2';
static const String start = 'start'; static const String start = 'start';
static const String stateHint = 'stateHint'; static const String stateHint = 'stateHint';