From 0ae6e3c02f4407588fff739d018e9e37c610e0a0 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Tue, 28 Oct 2025 06:23:11 +0100 Subject: [PATCH] fix: prevent custom scale dialog from closing when interacting with slider Wrapped MobileCustomScaleControls in GestureDetector with opaque behavior to prevent touch events from propagating to parent dialog's clickMaskDismiss handler. The slider now works correctly without closing the dialog. Signed-off-by: Alessandro De Blasis --- flutter/lib/mobile/pages/remote_page.dart | 9 + .../mobile/widgets/custom_scale_widget.dart | 201 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 flutter/lib/mobile/widgets/custom_scale_widget.dart diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 3e219ee91..74d3f7a3f 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -25,6 +25,7 @@ import '../../models/model.dart'; import '../../models/platform_model.dart'; import '../../utils/image.dart'; import '../widgets/dialog.dart'; +import '../widgets/custom_scale_widget.dart'; final initText = '1' * 1024; @@ -1201,6 +1202,14 @@ void showOptions( if (v != null) viewStyle.value = v; } : null)), + // Show custom scale controls when custom view style is selected + Obx(() => viewStyle.value == kRemoteViewStyleCustom + ? GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, // Absorb tap events to prevent dialog dismiss + child: MobileCustomScaleControls(ffi: gFFI), + ) + : SizedBox.shrink()), const Divider(color: MyTheme.border), for (var e in imageQualityRadios) Obx(() => getRadio( diff --git a/flutter/lib/mobile/widgets/custom_scale_widget.dart b/flutter/lib/mobile/widgets/custom_scale_widget.dart new file mode 100644 index 000000000..7807d3e2d --- /dev/null +++ b/flutter/lib/mobile/widgets/custom_scale_widget.dart @@ -0,0 +1,201 @@ +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'; + +class MobileCustomScaleControls extends StatefulWidget { + final FFI ffi; + final ValueChanged? onChanged; + const MobileCustomScaleControls({Key? key, required this.ffi, this.onChanged}) : super(key: key); + + @override + 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; + } + + @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(); + } + + @override + Widget build(BuildContext context) { + // Smaller button size for mobile + const smallBtnConstraints = BoxConstraints(minWidth: 32, minHeight: 32); + + final sliderControl = Slider( + value: _pos, + 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; + } + }, + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${translate("Scale custom")}: $_value%', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + SizedBox(height: 8), + Row( + children: [ + IconButton( + iconSize: 20, + padding: EdgeInsets.all(4), + constraints: smallBtnConstraints, + icon: const Icon(Icons.remove), + tooltip: translate('Decrease'), + onPressed: () => _nudge(-1), + ), + Expanded(child: sliderControl), + IconButton( + iconSize: 20, + padding: EdgeInsets.all(4), + constraints: smallBtnConstraints, + icon: const Icon(Icons.add), + tooltip: translate('Increase'), + onPressed: () => _nudge(1), + ), + ], + ), + ], + ), + ); + } +}