From 70dcc7a7f8f28bafedc6372f576298b4f07d95f9 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Wed, 29 Oct 2025 11:33:31 +0100 Subject: [PATCH] refactor: Implement CustomScaleControlsMixin for shared scaling logic across mobile and desktop widgets - Introduced a new mixin `CustomScaleControlsMixin` to encapsulate custom scale control logic, allowing for code reuse in both mobile and desktop widgets. - Refactored `_CustomScaleMenuControlsState` and `_MobileCustomScaleControlsState` to utilize the new mixin, simplifying the scaling logic and reducing code duplication. - Updated slider handling and state management to leverage the mixin's methods for improved maintainability. Signed-off-by: Alessandro De Blasis --- .../common/widgets/custom_scale_mixin.dart | 157 ++++++++++++++++++ .../lib/desktop/widgets/remote_toolbar.dart | 155 ++--------------- .../mobile/widgets/custom_scale_widget.dart | 155 ++--------------- 3 files changed, 186 insertions(+), 281 deletions(-) create mode 100644 flutter/lib/common/widgets/custom_scale_mixin.dart diff --git a/flutter/lib/common/widgets/custom_scale_mixin.dart b/flutter/lib/common/widgets/custom_scale_mixin.dart new file mode 100644 index 000000000..a3f8ce74f --- /dev/null +++ b/flutter/lib/common/widgets/custom_scale_mixin.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:debounce_throttle/debounce_throttle.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:flutter_hbb/utils/scale.dart'; +import 'package:flutter_hbb/common.dart'; + +/// Mixin providing shared custom scale control logic for both mobile and desktop widgets. +/// Implementations must provide [ffi] and [onScaleChanged] getters. +/// +/// Note: Members are intentionally not private (no underscore) to allow access from +/// implementing classes in different libraries. Implementing classes should treat +/// these as protected members. +mixin CustomScaleControlsMixin on State { + /// FFI instance for session interaction + FFI get ffi; + + /// Callback invoked when scale value changes + ValueChanged? get onScaleChanged; + + late int scaleValue; + late final Debouncer debouncerScale; + // Normalized slider position in [0, 1]. We map it nonlinearly to percent. + double scalePos = 0.0; + + // Piecewise mapping constants (from consts.dart) + static const int minPercent = kScaleCustomMinPercent; + static const int pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track + static const int maxPercent = kScaleCustomMaxPercent; + static const double pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100% + static const double detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%) + + // Clamp helper for local use + int clampScale(int v) => clampCustomScalePercent(v); + + // Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width. + int mapPosToPercent(double p) { + if (p <= 0.0) return minPercent; + if (p >= 1.0) return maxPercent; + if (p <= pivotPos) { + final q = p / pivotPos; // 0..1 + final v = minPercent + q * (pivotPercent - minPercent); + return clampScale(v.round()); + } else { + final q = (p - pivotPos) / (1.0 - pivotPos); // 0..1 + final v = pivotPercent + q * (maxPercent - pivotPercent); + return clampScale(v.round()); + } + } + + // Map percent [5,1000] → normalized position [0,1] + double mapPercentToPos(int percent) { + final p = clampScale(percent); + if (p <= pivotPercent) { + final q = (p - minPercent) / (pivotPercent - minPercent); + return q * pivotPos; + } else { + final q = (p - pivotPercent) / (maxPercent - pivotPercent); + return pivotPos + q * (1.0 - pivotPos); + } + } + + // Snap normalized position to the pivot when close to it + double snapNormalizedPos(double p) { + if ((p - pivotPos).abs() <= detentEpsilon) return pivotPos; + if (p < 0.0) return 0.0; + if (p > 1.0) return 1.0; + return p; + } + + @override + void initState() { + super.initState(); + scaleValue = 100; + debouncerScale = Debouncer( + kDebounceCustomScaleDuration, + onChanged: (v) async { + await applyScale(v); + }, + initialValue: scaleValue, + ); + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + final v = await getSessionCustomScalePercent(ffi.sessionId); + if (mounted) { + setState(() { + scaleValue = v; + scalePos = mapPercentToPos(v); + }); + } + } catch (e, st) { + debugPrint('[CustomScale] Failed to get initial value: $e'); + debugPrintStack(stackTrace: st); + } + }); + } + + Future applyScale(int v) async { + v = clampCustomScalePercent(v); + setState(() { + scaleValue = v; + }); + try { + await bind.sessionSetFlutterOption( + sessionId: ffi.sessionId, + k: kCustomScalePercentKey, + v: v.toString()); + final curStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId); + if (curStyle != kRemoteViewStyleCustom) { + await bind.sessionSetViewStyle( + sessionId: ffi.sessionId, value: kRemoteViewStyleCustom); + } + await ffi.canvasModel.updateViewStyle(); + if (isMobile) { + HapticFeedback.selectionClick(); + } + onScaleChanged?.call(v); + } catch (e, st) { + debugPrint('[CustomScale] Apply failed: $e'); + debugPrintStack(stackTrace: st); + } + } + + void nudgeScale(int delta) { + final next = clampScale(scaleValue + delta); + setState(() { + scaleValue = next; + scalePos = mapPercentToPos(next); + }); + onScaleChanged?.call(next); + debouncerScale.value = next; + } + + @override + void dispose() { + debouncerScale.cancel(); + super.dispose(); + } + + // Callback for slider changes - implementations can call this from their build method + void onSliderChanged(double v) { + final snapped = snapNormalizedPos(v); + final next = mapPosToPercent(snapped); + if (next != scaleValue || snapped != scalePos) { + setState(() { + scalePos = snapped; + scaleValue = next; + }); + onScaleChanged?.call(next); + debouncerScale.value = next; + } + } +} diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 84b741d00..e2a74c115 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -26,6 +26,7 @@ import '../../common/shared_state.dart'; import './popup_menu.dart'; import './kb_layout_type_chooser.dart'; import 'package:flutter_hbb/utils/scale.dart'; +import 'package:flutter_hbb/common/widgets/custom_scale_mixin.dart'; class ToolbarState { late RxBool _pin; @@ -1189,126 +1190,13 @@ class _CustomScaleMenuControls extends StatefulWidget { State<_CustomScaleMenuControls> createState() => _CustomScaleMenuControlsState(); } -class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> { - late int _value; - late final Debouncer _debouncerScale; - // Normalized slider position in [0, 1]. We map it nonlinearly to percent. - double _pos = 0.0; - - // Piecewise mapping constants (moved to consts.dart) - static const int _minPercent = kScaleCustomMinPercent; - static const int _pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track - static const int _maxPercent = kScaleCustomMaxPercent; - static const double _pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100% - static const double _detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%) - - // Clamp helper for local use - int _clamp(int v) => clampCustomScalePercent(v); - - // Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width. - int _mapPosToPercent(double p) { - if (p <= 0.0) return _minPercent; - if (p >= 1.0) return _maxPercent; - if (p <= _pivotPos) { - final q = p / _pivotPos; // 0..1 - final v = _minPercent + q * (_pivotPercent - _minPercent); - return _clamp(v.round()); - } else { - final q = (p - _pivotPos) / (1.0 - _pivotPos); // 0..1 - final v = _pivotPercent + q * (_maxPercent - _pivotPercent); - return _clamp(v.round()); - } - } - - // Map percent [5,1000] → normalized position [0,1] - double _mapPercentToPos(int percent) { - final p = _clamp(percent); - if (p <= _pivotPercent) { - final q = (p - _minPercent) / (_pivotPercent - _minPercent); - return q * _pivotPos; - } else { - final q = (p - _pivotPercent) / (_maxPercent - _pivotPercent); - return _pivotPos + q * (1.0 - _pivotPos); - } - } - - // Snap normalized position to the pivot when close to it - double _snapNormalizedPos(double p) { - if ((p - _pivotPos).abs() <= _detentEpsilon) return _pivotPos; - if (p < 0.0) return 0.0; - if (p > 1.0) return 1.0; - return p; - } +class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> + with CustomScaleControlsMixin { + @override + FFI get ffi => widget.ffi; @override - void initState() { - super.initState(); - _value = 100; - _debouncerScale = Debouncer( - kDebounceCustomScaleDuration, - onChanged: (v) async { - await _apply(v); - }, - initialValue: _value, - ); - WidgetsBinding.instance.addPostFrameCallback((_) async { - try { - final v = await getSessionCustomScalePercent(widget.ffi.sessionId); - if (mounted) { - setState(() { - _value = v; - _pos = _mapPercentToPos(v); - }); - } - } catch (e, st) { - debugPrint('[CustomScale] Failed to get initial value: $e'); - debugPrintStack(stackTrace: st); - } - }); - } - - - Future _apply(int v) async { - v = clampCustomScalePercent(v); - setState(() { - _value = v; - }); - try { - await bind.sessionSetFlutterOption( - sessionId: widget.ffi.sessionId, - k: kCustomScalePercentKey, - v: v.toString()); - final curStyle = await bind.sessionGetViewStyle(sessionId: widget.ffi.sessionId); - if (curStyle != kRemoteViewStyleCustom) { - await bind.sessionSetViewStyle( - sessionId: widget.ffi.sessionId, value: kRemoteViewStyleCustom); - } - await widget.ffi.canvasModel.updateViewStyle(); - if (isMobile) { - HapticFeedback.selectionClick(); - } - widget.onChanged?.call(v); - } catch (e, st) { - debugPrint('[CustomScale] Apply failed: $e'); - debugPrintStack(stackTrace: st); - } - } - - void _nudge(int delta) { - final next = _clamp(_value + delta); - setState(() { - _value = next; - _pos = _mapPercentToPos(next); - }); - widget.onChanged?.call(next); - _debouncerScale.value = next; - } - - @override - void dispose() { - _debouncerScale.cancel(); - super.dispose(); - } + ValueChanged? get onScaleChanged => widget.onChanged; @override Widget build(BuildContext context) { @@ -1317,7 +1205,7 @@ class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> { final sliderControl = Semantics( label: translate('Custom scale slider'), - value: '$_value%', + value: '$scaleValue%', child: SliderTheme( data: SliderTheme.of(context).copyWith( activeTrackColor: colorScheme.primary, @@ -1325,34 +1213,23 @@ class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> { overlayColor: colorScheme.primary.withOpacity(0.1), showValueIndicator: ShowValueIndicator.never, thumbShape: _RectValueThumbShape( - min: _minPercent.toDouble(), - max: _maxPercent.toDouble(), + min: CustomScaleControlsMixin.minPercent.toDouble(), + max: CustomScaleControlsMixin.maxPercent.toDouble(), width: 52, height: 24, radius: 4, // Display the mapped percent for the current normalized value - displayValueForNormalized: (t) => _mapPosToPercent(t), + displayValueForNormalized: (t) => mapPosToPercent(t), ), ), child: Slider( - value: _pos, + value: scalePos, min: 0.0, max: 1.0, - // Use a wide range of divisions (calculated as (_maxPercent - _minPercent)) to provide ~1% precision increments. + // Use a wide range of divisions (calculated as (maxPercent - minPercent)) to provide ~1% precision increments. // This allows users to set precise scale values. Lower values would require more fine-tuning via the +/- buttons, which is undesirable for big ranges. - divisions: (_maxPercent - _minPercent).round(), - onChanged: (v) { - final snapped = _snapNormalizedPos(v); - final next = _mapPosToPercent(snapped); - if (next != _value || snapped != _pos) { - setState(() { - _pos = snapped; - _value = next; - }); - widget.onChanged?.call(next); - _debouncerScale.value = next; - } - }, + divisions: (CustomScaleControlsMixin.maxPercent - CustomScaleControlsMixin.minPercent).round(), + onChanged: onSliderChanged, ), ), ); @@ -1368,7 +1245,7 @@ class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> { padding: EdgeInsets.all(1), constraints: smallBtnConstraints, icon: const Icon(Icons.remove), - onPressed: () => _nudge(-1), + onPressed: () => nudgeScale(-1), ), ), Expanded(child: sliderControl), @@ -1379,7 +1256,7 @@ class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> { padding: EdgeInsets.all(1), constraints: smallBtnConstraints, icon: const Icon(Icons.add), - onPressed: () => _nudge(1), + onPressed: () => nudgeScale(1), ), ), ]), diff --git a/flutter/lib/mobile/widgets/custom_scale_widget.dart b/flutter/lib/mobile/widgets/custom_scale_widget.dart index 3993544c9..b59622263 100644 --- a/flutter/lib/mobile/widgets/custom_scale_widget.dart +++ b/flutter/lib/mobile/widgets/custom_scale_widget.dart @@ -1,13 +1,7 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:debounce_throttle/debounce_throttle.dart'; -import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/models/model.dart'; -import 'package:flutter_hbb/models/platform_model.dart'; -import 'package:flutter_hbb/utils/scale.dart'; import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/common/widgets/custom_scale_mixin.dart'; class MobileCustomScaleControls extends StatefulWidget { final FFI ffi; @@ -18,125 +12,13 @@ class MobileCustomScaleControls extends StatefulWidget { State createState() => _MobileCustomScaleControlsState(); } -class _MobileCustomScaleControlsState extends State { - late int _value; - late final Debouncer _debouncerScale; - // Normalized slider position in [0, 1]. We map it nonlinearly to percent. - double _pos = 0.0; - - // Piecewise mapping constants (from consts.dart) - static const int _minPercent = kScaleCustomMinPercent; - static const int _pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track - static const int _maxPercent = kScaleCustomMaxPercent; - static const double _pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100% - static const double _detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%) - - // Clamp helper for local use - int _clamp(int v) => clampCustomScalePercent(v); - - // Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width. - int _mapPosToPercent(double p) { - if (p <= 0.0) return _minPercent; - if (p >= 1.0) return _maxPercent; - if (p <= _pivotPos) { - final q = p / _pivotPos; // 0..1 - final v = _minPercent + q * (_pivotPercent - _minPercent); - return _clamp(v.round()); - } else { - final q = (p - _pivotPos) / (1.0 - _pivotPos); // 0..1 - final v = _pivotPercent + q * (_maxPercent - _pivotPercent); - return _clamp(v.round()); - } - } - - // Map percent [5,1000] → normalized position [0,1] - double _mapPercentToPos(int percent) { - final p = _clamp(percent); - if (p <= _pivotPercent) { - final q = (p - _minPercent) / (_pivotPercent - _minPercent); - return q * _pivotPos; - } else { - final q = (p - _pivotPercent) / (_maxPercent - _pivotPercent); - return _pivotPos + q * (1.0 - _pivotPos); - } - } - - // Snap normalized position to the pivot when close to it - double _snapNormalizedPos(double p) { - if ((p - _pivotPos).abs() <= _detentEpsilon) return _pivotPos; - if (p < 0.0) return 0.0; - if (p > 1.0) return 1.0; - return p; - } +class _MobileCustomScaleControlsState extends State + with CustomScaleControlsMixin { + @override + FFI get ffi => widget.ffi; @override - void initState() { - super.initState(); - _value = 100; - _debouncerScale = Debouncer( - kDebounceCustomScaleDuration, - onChanged: (v) async { - await _apply(v); - }, - initialValue: _value, - ); - WidgetsBinding.instance.addPostFrameCallback((_) async { - try { - final v = await getSessionCustomScalePercent(widget.ffi.sessionId); - if (mounted) { - setState(() { - _value = v; - _pos = _mapPercentToPos(v); - }); - } - } catch (e, st) { - debugPrint('[CustomScale] Failed to get initial value: $e'); - debugPrintStack(stackTrace: st); - } - }); - } - - Future _apply(int v) async { - v = clampCustomScalePercent(v); - setState(() { - _value = v; - }); - try { - await bind.sessionSetFlutterOption( - sessionId: widget.ffi.sessionId, - k: kCustomScalePercentKey, - v: v.toString()); - final curStyle = await bind.sessionGetViewStyle(sessionId: widget.ffi.sessionId); - if (curStyle != kRemoteViewStyleCustom) { - await bind.sessionSetViewStyle( - sessionId: widget.ffi.sessionId, value: kRemoteViewStyleCustom); - } - await widget.ffi.canvasModel.updateViewStyle(); - if (isMobile) { - HapticFeedback.selectionClick(); - } - widget.onChanged?.call(v); - } catch (e, st) { - debugPrint('[CustomScale] Apply failed: $e'); - debugPrintStack(stackTrace: st); - } - } - - void _nudge(int delta) { - final next = _clamp(_value + delta); - setState(() { - _value = next; - _pos = _mapPercentToPos(next); - }); - widget.onChanged?.call(next); - _debouncerScale.value = next; - } - - @override - void dispose() { - _debouncerScale.cancel(); - super.dispose(); - } + ValueChanged? get onScaleChanged => widget.onChanged; @override Widget build(BuildContext context) { @@ -144,23 +26,12 @@ class _MobileCustomScaleControlsState extends State { const smallBtnConstraints = BoxConstraints(minWidth: 32, minHeight: 32); final sliderControl = Slider( - value: _pos, + value: scalePos, min: 0.0, max: 1.0, - divisions: (_maxPercent - _minPercent).round(), - label: '$_value%', - onChanged: (v) { - final snapped = _snapNormalizedPos(v); - final next = _mapPosToPercent(snapped); - if (next != _value || snapped != _pos) { - setState(() { - _pos = snapped; - _value = next; - }); - widget.onChanged?.call(next); - _debouncerScale.value = next; - } - }, + divisions: (CustomScaleControlsMixin.maxPercent - CustomScaleControlsMixin.minPercent).round(), + label: '$scaleValue%', + onChanged: onSliderChanged, ); return Padding( @@ -169,7 +40,7 @@ class _MobileCustomScaleControlsState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - '${translate("Scale custom")}: $_value%', + '${translate("Scale custom")}: $scaleValue%', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), ), const SizedBox(height: 8), @@ -181,7 +52,7 @@ class _MobileCustomScaleControlsState extends State { constraints: smallBtnConstraints, icon: const Icon(Icons.remove), tooltip: translate('Decrease'), - onPressed: () => _nudge(-1), + onPressed: () => nudgeScale(-1), ), Expanded(child: sliderControl), IconButton( @@ -190,7 +61,7 @@ class _MobileCustomScaleControlsState extends State { constraints: smallBtnConstraints, icon: const Icon(Icons.add), tooltip: translate('Increase'), - onPressed: () => _nudge(1), + onPressed: () => nudgeScale(1), ), ], ),