public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Shan Shaji <s.shaji@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH pve_flutter_frontend v2 2/3] feat: ui: add bottom sheet to change VMs/CTs options
Date: Tue, 23 Sep 2025 14:23:33 +0200	[thread overview]
Message-ID: <20250923122334.301053-3-s.shaji@proxmox.com> (raw)
In-Reply-To: <20250923122334.301053-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>
---
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<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


  parent reply	other threads:[~2025-09-23 12:23 UTC|newest]

Thread overview: 5+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-09-23 12:23 [pve-devel] [PATCH pve_flutter_frontend v2 0/3] feat: ui: add bottom sheet to show editable options of VMs/CTs Shan Shaji
2025-09-23 12:23 ` [pve-devel] [PATCH pve_flutter_frontend v2 1/3] cleanup: run `dart format` on both qemu and lxc options page Shan Shaji
2025-09-23 12:23 ` Shan Shaji [this message]
2025-09-23 12:23 ` [pve-devel] [PATCH pve_flutter_frontend v2 3/3] cleanup: remove `PveConfigSwitchListTile` in favor of PveConfigListTile Shan Shaji
2025-09-23 12:57 ` [pve-devel] [PATCH pve_flutter_frontend v2 0/3] feat: ui: add bottom sheet to show editable options of VMs/CTs Thomas Lamprecht

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=20250923122334.301053-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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal