From: Shan Shaji <s.shaji@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH pve_flutter_frontend 2/3] feat: ui: add bottom sheet to change VMs/CTs options
Date: Tue, 23 Sep 2025 11:36:28 +0200 [thread overview]
Message-ID: <20250923093629.119418-3-s.shaji@proxmox.com> (raw)
In-Reply-To: <20250923093629.119418-1-s.shaji@proxmox.com>
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 <s.shaji@proxmox.com>
---
.../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..61ae014
--- /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_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<bool> controller;
+
+ @override
+ State<PveConfigSwitchForm> createState() => _PveConfigSwitchFormState();
+}
+
+class _PveConfigSwitchFormState extends State<PveConfigSwitchForm> {
+ 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<T> = Widget Function(
+ BuildContext context,
+ PveConfigEditorController<T> controller,
+);
+
+class PveConfigEditorController<T> {
+ 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<T> 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<T> 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<T> extends StatefulWidget {
+ const _PveConfigEditorForm({
+ required this.title,
+ required this.editorBuilder,
+ required this.onSubmit,
+ });
+
+ final String title;
+ final PveConfigEditorBuilder<T> editorBuilder;
+ final void Function(T value) onSubmit;
+
+ @override
+ State<_PveConfigEditorForm<T>> createState() =>
+ _PveConfigEditorFormState<T>();
+}
+
+class _PveConfigEditorFormState<T> extends State<_PveConfigEditorForm<T>> {
+ bool _isDirty = false;
+ late T _currentValue;
+
+ late final PveConfigEditorController<T> _controller =
+ PveConfigEditorController<T>(
+ 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<bool>(
+ 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<bool>(
+ 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<bool>(
+ 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<bool>(
+ 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<bool>(
+ 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<bool>(
+ 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<bool>(
+ 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<bool>(
+ 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<bool>(
+ 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<bool>(
+ 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
next prev parent reply other threads:[~2025-09-23 9:37 UTC|newest]
Thread overview: 7+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-09-23 9:36 [pve-devel] [PATCH pve_flutter_frontend 0/3] feat: ui: add bottom sheet to show editable options of VMs/CTs Shan Shaji
2025-09-23 9:36 ` [pve-devel] [PATCH pve_flutter_frontend 1/3] cleanup: run `dart format` on both qemu and lxc options page Shan Shaji
2025-09-23 9:36 ` Shan Shaji [this message]
2025-09-23 12:03 ` [pve-devel] [PATCH pve_flutter_frontend 2/3] feat: ui: add bottom sheet to change VMs/CTs options Thomas Lamprecht
2025-09-23 12:12 ` Shan Shaji
2025-09-23 12:24 ` Shan Shaji
2025-09-23 9:36 ` [pve-devel] [PATCH pve_flutter_frontend 3/3] cleanup: remove `PveConfigSwitchListTile` in favor of PveConfigListTile Shan Shaji
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20250923093629.119418-3-s.shaji@proxmox.com \
--to=s.shaji@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox