From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 64D201FF191 for ; Tue, 23 Sep 2025 14:23:52 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 8ACECDEE1; Tue, 23 Sep 2025 14:24:20 +0200 (CEST) From: Shan Shaji To: pve-devel@lists.proxmox.com Date: Tue, 23 Sep 2025 14:23:33 +0200 Message-ID: <20250923122334.301053-3-s.shaji@proxmox.com> X-Mailer: git-send-email 2.47.2 In-Reply-To: <20250923122334.301053-1-s.shaji@proxmox.com> References: <20250923122334.301053-1-s.shaji@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1758630212173 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.147 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pve-devel] [PATCH pve_flutter_frontend v2 2/3] feat: ui: add bottom sheet to change VMs/CTs options X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox VE development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pve-devel-bounces@lists.proxmox.com Sender: "pve-devel" On the options page for VMs and CTs it was easy to change the configs by mistake. To avoid add a bottom sheet to show the editable option. The value will only be updated when the `update` button is pressed. Signed-off-by: Shan Shaji --- changes since v1 - Fixed file import error inside `pve_config_switch_form` .../pve_config_switch_form.dart | 54 ++++++ lib/widgets/pve_config_list_tile.dart | 176 +++++++++++++++++ lib/widgets/pve_lxc_options_widget.dart | 78 +++++--- lib/widgets/pve_qemu_options_widget.dart | 177 ++++++++++++------ 4 files changed, 400 insertions(+), 85 deletions(-) create mode 100644 lib/widgets/pve_config_forms/pve_config_switch_form.dart create mode 100644 lib/widgets/pve_config_list_tile.dart diff --git a/lib/widgets/pve_config_forms/pve_config_switch_form.dart b/lib/widgets/pve_config_forms/pve_config_switch_form.dart new file mode 100644 index 0000000..40bef8c --- /dev/null +++ b/lib/widgets/pve_config_forms/pve_config_switch_form.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:pve_flutter_frontend/widgets/pve_config_list_tile.dart'; + +class PveConfigSwitchForm extends StatefulWidget { + const PveConfigSwitchForm({ + super.key, + required this.value, + required this.title, + required this.controller, + }); + + final bool value; + final String title; + final PveConfigEditorController controller; + + @override + State createState() => _PveConfigSwitchFormState(); +} + +class _PveConfigSwitchFormState extends State { + late bool _currentValue; + + bool get _isDirty => _currentValue != widget.value; + + @override + void initState() { + super.initState(); + _currentValue = widget.value; + + widget.controller + ..setValue(_currentValue) + ..setDirty(false); + } + + void _handleChanged(bool value) { + setState(() { + _currentValue = value; + }); + + widget.controller + ..setValue(_currentValue) + ..setDirty(_isDirty); + } + + @override + Widget build(BuildContext context) { + return SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(widget.title), + value: _currentValue, + onChanged: _handleChanged, + ); + } +} diff --git a/lib/widgets/pve_config_list_tile.dart b/lib/widgets/pve_config_list_tile.dart new file mode 100644 index 0000000..84c4694 --- /dev/null +++ b/lib/widgets/pve_config_list_tile.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; + +typedef PveConfigEditorBuilder = Widget Function( + BuildContext context, + PveConfigEditorController controller, +); + +class PveConfigEditorController { + PveConfigEditorController({ + required void Function(bool) onDirtyChanged, + required void Function(T) onValueChanged, + }) : _onDirtyChanged = onDirtyChanged, + _onValueChanged = onValueChanged; + + final void Function(bool) _onDirtyChanged; + final void Function(T) _onValueChanged; + + void setDirty(bool value) => _onDirtyChanged(value); + void setValue(T value) => _onValueChanged(value); +} + +class PveConfigListTile extends StatelessWidget { + const PveConfigListTile({ + super.key, + required this.title, + required this.editorBuilder, + required this.onSubmit, + this.subtitle, + this.trailingText, + this.pending, + this.onCancelEdit, + }); + + final String title; + final String? subtitle; + final String? trailingText; + final PveConfigEditorBuilder editorBuilder; + final void Function(T value) onSubmit; + final VoidCallback? onCancelEdit; + final int? pending; + + static void _showBottomSheet( + BuildContext context, { + required Widget Function(BuildContext) builder, + }) { + showModalBottomSheet( + context: context, + builder: builder, + useSafeArea: true, + ); + } + + @override + Widget build(BuildContext context) { + return ListTile( + enabled: pending == null, + title: pending != null + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title), + Chip( + label: const Text('pending'), + backgroundColor: Colors.red, + onDeleted: onCancelEdit, + ), + ], + ) + : Text(title), + subtitle: subtitle == null ? null : Text(subtitle!), + trailing: trailingText != null + ? Text( + trailingText!, + style: const TextStyle(fontSize: 14), + ) + : null, + onTap: pending != null + ? null + : () { + _showBottomSheet( + context, + builder: (_) { + return _PveConfigEditorForm( + title: 'Edit: $title', + editorBuilder: editorBuilder, + onSubmit: onSubmit, + ); + }, + ); + }, + ); + } +} + +class _PveConfigEditorForm extends StatefulWidget { + const _PveConfigEditorForm({ + required this.title, + required this.editorBuilder, + required this.onSubmit, + }); + + final String title; + final PveConfigEditorBuilder editorBuilder; + final void Function(T value) onSubmit; + + @override + State<_PveConfigEditorForm> createState() => + _PveConfigEditorFormState(); +} + +class _PveConfigEditorFormState extends State<_PveConfigEditorForm> { + bool _isDirty = false; + late T _currentValue; + + late final PveConfigEditorController _controller = + PveConfigEditorController( + onDirtyChanged: _handleDirtyChanged, + onValueChanged: _handleValueChanged, + ); + + void _handleDirtyChanged(bool value) { + if (_isDirty != value) { + setState(() { + _isDirty = value; + }); + } + } + + void _handleValueChanged(T value) { + _currentValue = value; + } + + void _submit() { + widget.onSubmit(_currentValue); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + widget.editorBuilder(context, _controller), + const SizedBox(height: 12), + Align( + alignment: Alignment.bottomRight, + child: FilledButton( + onPressed: _isDirty ? _submit : null, + child: const Text('Update'), + ), + ), + const SizedBox(height: 8), + ], + ), + ); + } +} diff --git a/lib/widgets/pve_lxc_options_widget.dart b/lib/widgets/pve_lxc_options_widget.dart index 5b6e009..4bd263e 100644 --- a/lib/widgets/pve_lxc_options_widget.dart +++ b/lib/widgets/pve_lxc_options_widget.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import 'package:pve_flutter_frontend/bloc/pve_lxc_overview_bloc.dart'; import 'package:pve_flutter_frontend/states/pve_lxc_overview_state.dart'; import 'package:pve_flutter_frontend/widgets/proxmox_stream_builder_widget.dart'; -import 'package:pve_flutter_frontend/widgets/pve_config_switch_list_tile.dart'; +import 'package:pve_flutter_frontend/widgets/pve_config_forms/pve_config_switch_form.dart'; +import 'package:pve_flutter_frontend/widgets/pve_config_list_tile.dart'; class PveLxcOptions extends StatelessWidget { final PveLxcOverviewBloc? lxcBloc; @@ -24,15 +25,26 @@ class PveLxcOptions extends StatelessWidget { title: const Text("Name"), subtitle: Text(config.hostname ?? 'undefined'), ), - PveConfigSwitchListTile( - title: const Text("Start on boot"), - value: config.onboot, - defaultValue: false, + PveConfigListTile( + title: "Start on boot", + trailingText: config.onboot == true ? 'Yes' : 'No', pending: config.getPending('onboot'), - onChanged: (v) => - lxcBloc!.events.add(UpdateLxcConfigBool('onboot', v)), - onDeleted: () => - lxcBloc!.events.add(RevertPendingLxcConfig('onboot')), + onSubmit: (value) => lxcBloc!.events.add( + UpdateLxcConfigBool( + 'onboot', + value, + ), + ), + editorBuilder: (_, controller) { + return PveConfigSwitchForm( + title: "Start on boot", + value: config.onboot == true, + controller: controller, + ); + }, + onCancelEdit: () => lxcBloc!.events.add( + RevertPendingLxcConfig('onboot'), + ), ), ListTile( title: const Text("Start/Shutdown order"), @@ -46,15 +58,23 @@ class PveLxcOptions extends StatelessWidget { title: const Text("Architecture"), subtitle: Text("${config.arch}"), ), - PveConfigSwitchListTile( - title: const Text("/dev/console"), - value: config.console, - defaultValue: true, + PveConfigListTile( + title: "/dev/console", + trailingText: config.console == true ? 'Yes' : 'No', pending: config.getPending('console'), - onChanged: (v) => lxcBloc!.events - .add(UpdateLxcConfigBool('console', v)), - onDeleted: () => lxcBloc!.events - .add(RevertPendingLxcConfig('console')), + onSubmit: (v) => lxcBloc!.events.add( + UpdateLxcConfigBool('console', v), + ), + editorBuilder: (_, controller) { + return PveConfigSwitchForm( + title: "/dev/console", + value: config.console == true, + controller: controller, + ); + }, + onCancelEdit: () => lxcBloc!.events.add( + RevertPendingLxcConfig('console'), + ), ), ListTile( title: const Text("TTY Count"), @@ -64,15 +84,23 @@ class PveLxcOptions extends StatelessWidget { title: const Text("Console Mode"), subtitle: Text(config.cmode?.name ?? 'tty'), ), - PveConfigSwitchListTile( - title: const Text("Protection"), - value: config.protection, - defaultValue: false, + PveConfigListTile( + title: "Protection", + trailingText: config.protection == true ? 'Yes' : 'No', pending: config.getPending('protection'), - onChanged: (v) => lxcBloc!.events - .add(UpdateLxcConfigBool('protection', v)), - onDeleted: () => lxcBloc!.events - .add(RevertPendingLxcConfig('protection')), + onSubmit: (v) => lxcBloc!.events.add( + UpdateLxcConfigBool('protection', v), + ), + editorBuilder: (_, controller) { + return PveConfigSwitchForm( + title: "Protection", + value: config.protection == true, + controller: controller, + ); + }, + onCancelEdit: () => lxcBloc!.events.add( + RevertPendingLxcConfig('protection'), + ), ), ListTile( title: const Text("Unprivileged"), diff --git a/lib/widgets/pve_qemu_options_widget.dart b/lib/widgets/pve_qemu_options_widget.dart index 992a382..f8cb000 100644 --- a/lib/widgets/pve_qemu_options_widget.dart +++ b/lib/widgets/pve_qemu_options_widget.dart @@ -3,7 +3,8 @@ import 'package:provider/provider.dart'; import 'package:pve_flutter_frontend/bloc/pve_qemu_overview_bloc.dart'; import 'package:pve_flutter_frontend/states/pve_qemu_overview_state.dart'; import 'package:pve_flutter_frontend/widgets/proxmox_stream_builder_widget.dart'; -import 'package:pve_flutter_frontend/widgets/pve_config_switch_list_tile.dart'; +import 'package:pve_flutter_frontend/widgets/pve_config_forms/pve_config_switch_form.dart'; +import 'package:pve_flutter_frontend/widgets/pve_config_list_tile.dart'; class PveQemuOptions extends StatelessWidget { final String guestID; @@ -35,15 +36,23 @@ class PveQemuOptions extends StatelessWidget { title: const Text("Name"), subtitle: Text(config.name ?? 'VM$guestID'), ), - PveConfigSwitchListTile( - title: const Text("Start on boot"), - value: config.onboot, - defaultValue: false, + PveConfigListTile( + title: "Start on boot", + trailingText: config.onboot == true ? 'Yes' : 'No', pending: config.getPending('onboot'), - onChanged: (v) => - bloc.events.add(UpdateQemuConfigBool('onboot', v)), - onDeleted: () => - bloc.events.add(RevertPendingQemuConfig('onboot')), + onSubmit: (v) => bloc.events.add( + UpdateQemuConfigBool('onboot', v), + ), + editorBuilder: (_, controller) { + return PveConfigSwitchForm( + title: "Start on boot", + value: config.onboot == true, + controller: controller, + ); + }, + onCancelEdit: () => bloc.events.add( + RevertPendingQemuConfig('onboot'), + ), ), ListTile( title: const Text("Start/Shutdown order"), @@ -60,59 +69,99 @@ class PveQemuOptions extends StatelessWidget { title: const Text("Boot Device"), subtitle: Text(config.boot ?? 'Disk, Network, USB'), ), - PveConfigSwitchListTile( - title: const Text("Use tablet for pointer"), - value: config.tablet, - defaultValue: true, + PveConfigListTile( + title: "Use tablet for pointer", + trailingText: config.tablet == true ? 'Yes' : 'No', pending: config.getPending('tablet'), - onChanged: (v) => - bloc.events.add(UpdateQemuConfigBool('tablet', v)), - onDeleted: () => - bloc.events.add(RevertPendingQemuConfig('tablet')), + onSubmit: (v) => bloc.events.add( + UpdateQemuConfigBool('tablet', v), + ), + editorBuilder: (_, controller) { + return PveConfigSwitchForm( + title: "Use tablet for pointer", + value: config.tablet == true, + controller: controller, + ); + }, + onCancelEdit: () => bloc.events.add( + RevertPendingQemuConfig('tablet'), + ), ), ListTile( title: const Text("Hotplug"), subtitle: Text(config.hotplug ?? 'disk,network,usb'), ), - PveConfigSwitchListTile( - title: const Text("ACPI support"), - value: config.acpi, - defaultValue: true, + PveConfigListTile( + title: "ACPI support", + trailingText: config.acpi == true ? 'Yes' : 'No', pending: config.getPending('acpi'), - onChanged: (v) => - bloc.events.add(UpdateQemuConfigBool('acpi', v)), - onDeleted: () => - bloc.events.add(RevertPendingQemuConfig('acpi')), - ), - PveConfigSwitchListTile( - title: const Text("KVM hardware virtualization"), - value: config.kvm, - defaultValue: true, + onSubmit: (v) => bloc.events.add( + UpdateQemuConfigBool('acpi', v), + ), + editorBuilder: (_, controller) { + return PveConfigSwitchForm( + title: "ACPI support", + value: config.acpi == true, + controller: controller, + ); + }, + onCancelEdit: () => bloc.events.add( + RevertPendingQemuConfig('acpi'), + ), + ), + PveConfigListTile( + title: "KVM hardware virtualization", + trailingText: config.kvm == true ? 'Yes' : 'No', pending: config.getPending('kvm'), - onChanged: (v) => - bloc.events.add(UpdateQemuConfigBool('kvm', v)), - onDeleted: () => - bloc.events.add(RevertPendingQemuConfig('kvm')), - ), - PveConfigSwitchListTile( - title: const Text("Freeze CPU on startup"), - value: config.freeze, - defaultValue: false, + onSubmit: (v) => bloc.events.add( + UpdateQemuConfigBool('kvm', v), + ), + editorBuilder: (_, controller) { + return PveConfigSwitchForm( + title: "KVM hardware virtualization", + value: config.kvm == true, + controller: controller, + ); + }, + onCancelEdit: () => bloc.events.add( + RevertPendingQemuConfig('kvm'), + ), + ), + PveConfigListTile( + title: "Freeze CPU on startup", + trailingText: config.freeze == true ? 'Yes' : 'No', pending: config.getPending('freeze'), - onChanged: (v) => - bloc.events.add(UpdateQemuConfigBool('freeze', v)), - onDeleted: () => - bloc.events.add(RevertPendingQemuConfig('freeze')), - ), - PveConfigSwitchListTile( - title: const Text("Use local time for RTC"), - value: config.localtime, - defaultValue: false, + onSubmit: (v) => bloc.events.add( + UpdateQemuConfigBool('freeze', v), + ), + editorBuilder: (_, controller) { + return PveConfigSwitchForm( + title: "Freeze CPU on startup", + value: config.freeze == true, + controller: controller, + ); + }, + onCancelEdit: () => bloc.events.add( + RevertPendingQemuConfig('freeze'), + ), + ), + PveConfigListTile( + title: "Use local time for RTC", + trailingText: config.localtime == true ? 'Yes' : 'No', pending: config.getPending('localtime'), - onChanged: (v) => bloc.events - .add(UpdateQemuConfigBool('localtime', v)), - onDeleted: () => bloc.events - .add(RevertPendingQemuConfig('localtime')), + onSubmit: (v) => bloc.events.add( + UpdateQemuConfigBool('localtime', v), + ), + editorBuilder: (_, controller) { + return PveConfigSwitchForm( + title: "Use local time for RTC", + value: config.localtime == true, + controller: controller, + ); + }, + onCancelEdit: () => bloc.events.add( + RevertPendingQemuConfig('localtime'), + ), ), ListTile( title: const Text("RTC start date"), @@ -127,15 +176,23 @@ class PveQemuOptions extends StatelessWidget { title: const Text("QEMU Guest Agent"), subtitle: Text(config.agent ?? 'Default (disabled)'), ), - PveConfigSwitchListTile( - title: const Text("Protection"), - value: config.protection, - defaultValue: false, + PveConfigListTile( + title: "Protection", + trailingText: config.protection == true ? 'Yes' : 'No', pending: config.getPending('protection'), - onChanged: (v) => bloc.events - .add(UpdateQemuConfigBool('protection', v)), - onDeleted: () => bloc.events - .add(RevertPendingQemuConfig('protection')), + onSubmit: (v) => bloc.events.add( + UpdateQemuConfigBool('protection', v), + ), + editorBuilder: (_, controller) { + return PveConfigSwitchForm( + title: "Protection", + value: config.protection == true, + controller: controller, + ); + }, + onCancelEdit: () => bloc.events.add( + RevertPendingQemuConfig('protection'), + ), ), ListTile( title: const Text("Spice Enhancements"), -- 2.47.2 _______________________________________________ pve-devel mailing list pve-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel