mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 07:14:28 +01:00
opt.: pve dashboard (#307)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import 'package:toolbox/core/extension/context/locale.dart';
|
import 'package:toolbox/core/extension/context/locale.dart';
|
||||||
import 'package:toolbox/core/extension/duration.dart';
|
import 'package:toolbox/core/extension/duration.dart';
|
||||||
import 'package:toolbox/core/extension/numx.dart';
|
import 'package:toolbox/core/extension/numx.dart';
|
||||||
|
import 'package:toolbox/core/extension/order.dart';
|
||||||
|
|
||||||
enum PveResType {
|
enum PveResType {
|
||||||
lxc,
|
lxc,
|
||||||
@@ -61,8 +62,9 @@ sealed class PveResIface {
|
|||||||
abstract interface class PveCtrlIface {
|
abstract interface class PveCtrlIface {
|
||||||
String get node;
|
String get node;
|
||||||
String get id;
|
String get id;
|
||||||
bool get isRunning;
|
bool get available;
|
||||||
String get summary;
|
String get summary;
|
||||||
|
String get name;
|
||||||
}
|
}
|
||||||
|
|
||||||
final class PveLxc extends PveResIface implements PveCtrlIface {
|
final class PveLxc extends PveResIface implements PveCtrlIface {
|
||||||
@@ -73,6 +75,7 @@ final class PveLxc extends PveResIface implements PveCtrlIface {
|
|||||||
final int vmid;
|
final int vmid;
|
||||||
@override
|
@override
|
||||||
final String node;
|
final String node;
|
||||||
|
@override
|
||||||
final String name;
|
final String name;
|
||||||
@override
|
@override
|
||||||
final String status;
|
final String status;
|
||||||
@@ -131,11 +134,11 @@ final class PveLxc extends PveResIface implements PveCtrlIface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get isRunning => status == 'running';
|
bool get available => status == 'running';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get summary {
|
String get summary {
|
||||||
if (isRunning) {
|
if (available) {
|
||||||
return uptime.secondsToDuration().toStr;
|
return uptime.secondsToDuration().toStr;
|
||||||
}
|
}
|
||||||
return l10n.stopped;
|
return l10n.stopped;
|
||||||
@@ -150,6 +153,7 @@ final class PveQemu extends PveResIface implements PveCtrlIface {
|
|||||||
final int vmid;
|
final int vmid;
|
||||||
@override
|
@override
|
||||||
final String node;
|
final String node;
|
||||||
|
@override
|
||||||
final String name;
|
final String name;
|
||||||
@override
|
@override
|
||||||
final String status;
|
final String status;
|
||||||
@@ -208,11 +212,11 @@ final class PveQemu extends PveResIface implements PveCtrlIface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get isRunning => status == 'running';
|
bool get available => status == 'running';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get summary {
|
String get summary {
|
||||||
if (isRunning) {
|
if (available) {
|
||||||
return uptime.secondsToDuration().toStr;
|
return uptime.secondsToDuration().toStr;
|
||||||
}
|
}
|
||||||
return l10n.stopped;
|
return l10n.stopped;
|
||||||
@@ -269,12 +273,13 @@ final class PveNode extends PveResIface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class PveStorage extends PveResIface {
|
final class PveStorage extends PveResIface implements PveCtrlIface {
|
||||||
@override
|
@override
|
||||||
final String id;
|
final String id;
|
||||||
@override
|
@override
|
||||||
final PveResType type;
|
final PveResType type;
|
||||||
final String storage;
|
final String storage;
|
||||||
|
@override
|
||||||
final String node;
|
final String node;
|
||||||
@override
|
@override
|
||||||
final String status;
|
final String status;
|
||||||
@@ -311,14 +316,29 @@ final class PveStorage extends PveResIface {
|
|||||||
maxdisk: json['maxdisk'],
|
maxdisk: json['maxdisk'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get available => status == 'available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => storage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get summary {
|
||||||
|
if (available) {
|
||||||
|
return '${l10n.used}: ${disk.bytes2Str} / ${l10n.total}: ${maxdisk.bytes2Str}';
|
||||||
|
}
|
||||||
|
return l10n.notAvailable;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class PveSdn extends PveResIface {
|
final class PveSdn extends PveResIface implements PveCtrlIface {
|
||||||
@override
|
@override
|
||||||
final String id;
|
final String id;
|
||||||
@override
|
@override
|
||||||
final PveResType type;
|
final PveResType type;
|
||||||
final String sdn;
|
final String sdn;
|
||||||
|
@override
|
||||||
final String node;
|
final String node;
|
||||||
@override
|
@override
|
||||||
final String status;
|
final String status;
|
||||||
@@ -340,6 +360,15 @@ final class PveSdn extends PveResIface {
|
|||||||
status: json['status'],
|
status: json['status'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get available => status == 'ok';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => sdn;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get summary => available ? status : l10n.notAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
final class PveRes {
|
final class PveRes {
|
||||||
@@ -357,6 +386,8 @@ final class PveRes {
|
|||||||
required this.sdns,
|
required this.sdns,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get onlyOneNode => nodes.length == 1;
|
||||||
|
|
||||||
int get length =>
|
int get length =>
|
||||||
qemus.length + lxcs.length + nodes.length + storages.length + sdns.length;
|
qemus.length + lxcs.length + nodes.length + storages.length + sdns.length;
|
||||||
|
|
||||||
@@ -379,4 +410,59 @@ final class PveRes {
|
|||||||
index -= storages.length;
|
index -= storages.length;
|
||||||
return sdns[index];
|
return sdns[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<PveRes> parse((List list, PveRes? old) val) async {
|
||||||
|
final (list, old) = val;
|
||||||
|
final items = list.map((e) => PveResIface.fromJson(e)).toList();
|
||||||
|
final Order<PveQemu> qemus = [];
|
||||||
|
final Order<PveLxc> lxcs = [];
|
||||||
|
final Order<PveNode> nodes = [];
|
||||||
|
final Order<PveStorage> storages = [];
|
||||||
|
final Order<PveSdn> sdns = [];
|
||||||
|
for (final item in items) {
|
||||||
|
switch (item.type) {
|
||||||
|
case PveResType.lxc:
|
||||||
|
lxcs.add(item as PveLxc);
|
||||||
|
break;
|
||||||
|
case PveResType.qemu:
|
||||||
|
qemus.add(item as PveQemu);
|
||||||
|
break;
|
||||||
|
case PveResType.node:
|
||||||
|
nodes.add(item as PveNode);
|
||||||
|
break;
|
||||||
|
case PveResType.storage:
|
||||||
|
storages.add(item as PveStorage);
|
||||||
|
break;
|
||||||
|
case PveResType.sdn:
|
||||||
|
sdns.add(item as PveSdn);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (old != null) {
|
||||||
|
qemus.reorder(
|
||||||
|
order: old.qemus.map((e) => e.id).toList(),
|
||||||
|
finder: (e, s) => e.id == s);
|
||||||
|
lxcs.reorder(
|
||||||
|
order: old.lxcs.map((e) => e.id).toList(),
|
||||||
|
finder: (e, s) => e.id == s);
|
||||||
|
nodes.reorder(
|
||||||
|
order: old.nodes.map((e) => e.id).toList(),
|
||||||
|
finder: (e, s) => e.id == s);
|
||||||
|
storages.reorder(
|
||||||
|
order: old.storages.map((e) => e.id).toList(),
|
||||||
|
finder: (e, s) => e.id == s);
|
||||||
|
sdns.reorder(
|
||||||
|
order: old.sdns.map((e) => e.id).toList(),
|
||||||
|
finder: (e, s) => e.id == s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PveRes(
|
||||||
|
qemus: qemus,
|
||||||
|
lxcs: lxcs,
|
||||||
|
nodes: nodes,
|
||||||
|
storages: storages,
|
||||||
|
sdns: sdns,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:computer/computer.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:toolbox/core/extension/order.dart';
|
|
||||||
import 'package:toolbox/data/model/server/pve.dart';
|
import 'package:toolbox/data/model/server/pve.dart';
|
||||||
import 'package:toolbox/data/model/server/server_private_info.dart';
|
import 'package:toolbox/data/model/server/server_private_info.dart';
|
||||||
|
import 'package:toolbox/data/res/logger.dart';
|
||||||
|
|
||||||
|
typedef PveCtrlFunc = Future<bool> Function(String node, String id);
|
||||||
|
|
||||||
final class PveProvider extends ChangeNotifier {
|
final class PveProvider extends ChangeNotifier {
|
||||||
final ServerPrivateInfo spi;
|
final ServerPrivateInfo spi;
|
||||||
late final String addr;
|
late final String addr;
|
||||||
//late final SSHClient _client;
|
//late final SSHClient _client;
|
||||||
|
|
||||||
final data = ValueNotifier<PveRes?>(null);
|
PveProvider({required this.spi}) {
|
||||||
|
|
||||||
PveProvider({
|
|
||||||
required this.spi,
|
|
||||||
}) {
|
|
||||||
// final client = _spi.server?.client;
|
// final client = _spi.server?.client;
|
||||||
// if (client == null) {
|
// if (client == null) {
|
||||||
// throw Exception('Server client is null');
|
// throw Exception('Server client is null');
|
||||||
@@ -27,19 +26,31 @@ final class PveProvider extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.addr = addr;
|
this.addr = addr;
|
||||||
_init().then((_) => connected.complete());
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
final err = ValueNotifier<String?>(null);
|
final err = ValueNotifier<String?>(null);
|
||||||
final connected = Completer<void>();
|
final connected = Completer<void>();
|
||||||
final session = Dio();
|
final session = Dio();
|
||||||
|
final data = ValueNotifier<PveRes?>(null);
|
||||||
|
bool get onlyOneNode => data.value?.nodes.length == 1;
|
||||||
|
String? release;
|
||||||
|
bool isBusy = false;
|
||||||
|
|
||||||
// int _localPort = 0;
|
// int _localPort = 0;
|
||||||
// String get addr => 'http://127.0.0.1:$_localPort';
|
// String get addr => 'http://127.0.0.1:$_localPort';
|
||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
|
try {
|
||||||
//await _forward();
|
//await _forward();
|
||||||
await _login();
|
await _login();
|
||||||
|
await _release;
|
||||||
|
} catch (e) {
|
||||||
|
Loggers.app.warning('PVE init failed', e);
|
||||||
|
err.value = e.toString();
|
||||||
|
} finally {
|
||||||
|
connected.complete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Future<void> _forward() async {
|
// Future<void> _forward() async {
|
||||||
@@ -75,67 +86,32 @@ final class PveProvider extends ChangeNotifier {
|
|||||||
session.options.headers['Cookie'] = 'PVEAuthCookie=$ticket';
|
session.options.headers['Cookie'] = 'PVEAuthCookie=$ticket';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<PveRes> list() async {
|
/// Returns true if the PVE version is 8.0 or later
|
||||||
|
Future<void> get _release async {
|
||||||
|
final resp = await session.get('$addr/api2/extjs/version');
|
||||||
|
final version = resp.data['data']['release'] as String?;
|
||||||
|
if (version != null) {
|
||||||
|
release = version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> list() async {
|
||||||
await connected.future;
|
await connected.future;
|
||||||
|
if (isBusy) return;
|
||||||
|
isBusy = true;
|
||||||
|
try {
|
||||||
final resp = await session.get('$addr/api2/json/cluster/resources');
|
final resp = await session.get('$addr/api2/json/cluster/resources');
|
||||||
final list = resp.data['data'] as List;
|
final res = resp.data['data'] as List;
|
||||||
final items = list.map((e) => PveResIface.fromJson(e)).toList();
|
final result = await Computer.shared.start(PveRes.parse, (res, data.value));
|
||||||
|
data.value = result;
|
||||||
final Order<PveQemu> qemus = [];
|
} catch (e) {
|
||||||
final Order<PveLxc> lxcs = [];
|
Loggers.app.warning('PVE list failed', e);
|
||||||
final Order<PveNode> nodes = [];
|
err.value = e.toString();
|
||||||
final Order<PveStorage> storages = [];
|
} finally {
|
||||||
final Order<PveSdn> sdns = [];
|
isBusy = false;
|
||||||
for (final item in items) {
|
|
||||||
switch (item.type) {
|
|
||||||
case PveResType.lxc:
|
|
||||||
lxcs.add(item as PveLxc);
|
|
||||||
break;
|
|
||||||
case PveResType.qemu:
|
|
||||||
qemus.add(item as PveQemu);
|
|
||||||
break;
|
|
||||||
case PveResType.node:
|
|
||||||
nodes.add(item as PveNode);
|
|
||||||
break;
|
|
||||||
case PveResType.storage:
|
|
||||||
storages.add(item as PveStorage);
|
|
||||||
break;
|
|
||||||
case PveResType.sdn:
|
|
||||||
sdns.add(item as PveSdn);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final old = data.value;
|
|
||||||
if (old != null) {
|
|
||||||
qemus.reorder(
|
|
||||||
order: old.qemus.map((e) => e.id).toList(),
|
|
||||||
finder: (e, s) => e.id == s);
|
|
||||||
lxcs.reorder(
|
|
||||||
order: old.lxcs.map((e) => e.id).toList(),
|
|
||||||
finder: (e, s) => e.id == s);
|
|
||||||
nodes.reorder(
|
|
||||||
order: old.nodes.map((e) => e.id).toList(),
|
|
||||||
finder: (e, s) => e.id == s);
|
|
||||||
storages.reorder(
|
|
||||||
order: old.storages.map((e) => e.id).toList(),
|
|
||||||
finder: (e, s) => e.id == s);
|
|
||||||
sdns.reorder(
|
|
||||||
order: old.sdns.map((e) => e.id).toList(),
|
|
||||||
finder: (e, s) => e.id == s);
|
|
||||||
}
|
|
||||||
|
|
||||||
final res = PveRes(
|
|
||||||
qemus: qemus,
|
|
||||||
lxcs: lxcs,
|
|
||||||
nodes: nodes,
|
|
||||||
storages: storages,
|
|
||||||
sdns: sdns,
|
|
||||||
);
|
|
||||||
data.value = res;
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> reboot(String node, String id) async {
|
Future<bool> reboot(String node, String id) async {
|
||||||
await connected.future;
|
await connected.future;
|
||||||
final resp =
|
final resp =
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
"noTask": "Nicht fragen",
|
"noTask": "Nicht fragen",
|
||||||
"noUpdateAvailable": "Kein Update verfügbar",
|
"noUpdateAvailable": "Kein Update verfügbar",
|
||||||
"node": "Knoten",
|
"node": "Knoten",
|
||||||
|
"notAvailable": "Nicht verfügbar",
|
||||||
"notSelected": "Nicht ausgewählt",
|
"notSelected": "Nicht ausgewählt",
|
||||||
"note": "Hinweis",
|
"note": "Hinweis",
|
||||||
"nullToken": "Null token",
|
"nullToken": "Null token",
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
"privateKey": "Private Key",
|
"privateKey": "Private Key",
|
||||||
"process": "Prozess",
|
"process": "Prozess",
|
||||||
"pushToken": "Push Token",
|
"pushToken": "Push Token",
|
||||||
|
"pveVersionLow": "Diese Funktion befindet sich derzeit in der Testphase und wurde nur auf PVE 8+ getestet. Bitte verwenden Sie sie mit Vorsicht.",
|
||||||
"pwd": "Passwort",
|
"pwd": "Passwort",
|
||||||
"read": "Lesen",
|
"read": "Lesen",
|
||||||
"reboot": "Neustart",
|
"reboot": "Neustart",
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
"noTask": "No task",
|
"noTask": "No task",
|
||||||
"noUpdateAvailable": "No update available",
|
"noUpdateAvailable": "No update available",
|
||||||
"node": "Node",
|
"node": "Node",
|
||||||
|
"notAvailable": "Unavailable",
|
||||||
"notSelected": "Not selected",
|
"notSelected": "Not selected",
|
||||||
"note": "Note",
|
"note": "Note",
|
||||||
"nullToken": "Null token",
|
"nullToken": "Null token",
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
"privateKey": "Private Key",
|
"privateKey": "Private Key",
|
||||||
"process": "Process",
|
"process": "Process",
|
||||||
"pushToken": "Push token",
|
"pushToken": "Push token",
|
||||||
|
"pveVersionLow": "This feature is currently in the testing phase and has only been tested on PVE 8+. Please use it with caution.",
|
||||||
"pwd": "Password",
|
"pwd": "Password",
|
||||||
"read": "Read",
|
"read": "Read",
|
||||||
"reboot": "Reboot",
|
"reboot": "Reboot",
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
"noTask": "Sin tareas",
|
"noTask": "Sin tareas",
|
||||||
"noUpdateAvailable": "No hay actualizaciones disponibles",
|
"noUpdateAvailable": "No hay actualizaciones disponibles",
|
||||||
"node": "Nodo",
|
"node": "Nodo",
|
||||||
|
"notAvailable": "No disponible",
|
||||||
"notSelected": "No seleccionado",
|
"notSelected": "No seleccionado",
|
||||||
"note": "Nota",
|
"note": "Nota",
|
||||||
"nullToken": "Token nulo",
|
"nullToken": "Token nulo",
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
"privateKey": "Llave privada",
|
"privateKey": "Llave privada",
|
||||||
"process": "Proceso",
|
"process": "Proceso",
|
||||||
"pushToken": "Token de notificaciones",
|
"pushToken": "Token de notificaciones",
|
||||||
|
"pveVersionLow": "Esta función está actualmente en fase de prueba y solo se ha probado en PVE 8+. Úsela con precaución.",
|
||||||
"pwd": "Contraseña",
|
"pwd": "Contraseña",
|
||||||
"read": "Leer",
|
"read": "Leer",
|
||||||
"reboot": "Reiniciar",
|
"reboot": "Reiniciar",
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
"noTask": "Aucune tâche",
|
"noTask": "Aucune tâche",
|
||||||
"noUpdateAvailable": "Aucune mise à jour disponible",
|
"noUpdateAvailable": "Aucune mise à jour disponible",
|
||||||
"node": "Noeud",
|
"node": "Noeud",
|
||||||
|
"notAvailable": "Non disponible",
|
||||||
"notSelected": "Non sélectionné",
|
"notSelected": "Non sélectionné",
|
||||||
"note": "Note",
|
"note": "Note",
|
||||||
"nullToken": "Jeton nul",
|
"nullToken": "Jeton nul",
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
"privateKey": "Clé privée",
|
"privateKey": "Clé privée",
|
||||||
"process": "Processus",
|
"process": "Processus",
|
||||||
"pushToken": "Jeton d'identification",
|
"pushToken": "Jeton d'identification",
|
||||||
|
"pveVersionLow": "Cette fonctionnalité est actuellement en phase de test et n'a été testée que sur PVE 8+. Veuillez l'utiliser avec prudence.",
|
||||||
"pwd": "Mot de passe",
|
"pwd": "Mot de passe",
|
||||||
"read": "Lire",
|
"read": "Lire",
|
||||||
"reboot": "Redémarrer",
|
"reboot": "Redémarrer",
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
"noTask": "Tidak bertanya",
|
"noTask": "Tidak bertanya",
|
||||||
"noUpdateAvailable": "Tidak ada pembaruan yang tersedia",
|
"noUpdateAvailable": "Tidak ada pembaruan yang tersedia",
|
||||||
"node": "Node",
|
"node": "Node",
|
||||||
|
"notAvailable": "Tidak tersedia",
|
||||||
"notSelected": "Tidak terpilih",
|
"notSelected": "Tidak terpilih",
|
||||||
"note": "Catatan",
|
"note": "Catatan",
|
||||||
"nullToken": "Token NULL",
|
"nullToken": "Token NULL",
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
"privateKey": "Kunci Pribadi",
|
"privateKey": "Kunci Pribadi",
|
||||||
"process": "Proses",
|
"process": "Proses",
|
||||||
"pushToken": "Dorong token",
|
"pushToken": "Dorong token",
|
||||||
|
"pveVersionLow": "Fitur ini saat ini sedang dalam tahap pengujian dan hanya diuji pada PVE 8+. Gunakan dengan hati-hati.",
|
||||||
"pwd": "Kata sandi",
|
"pwd": "Kata sandi",
|
||||||
"read": "Baca",
|
"read": "Baca",
|
||||||
"reboot": "Reboot",
|
"reboot": "Reboot",
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
"noTask": "タスクがありません",
|
"noTask": "タスクがありません",
|
||||||
"noUpdateAvailable": "利用可能な更新はありません",
|
"noUpdateAvailable": "利用可能な更新はありません",
|
||||||
"node": "ノード",
|
"node": "ノード",
|
||||||
|
"notAvailable": "利用不可",
|
||||||
"notSelected": "選択されていません",
|
"notSelected": "選択されていません",
|
||||||
"note": "メモ",
|
"note": "メモ",
|
||||||
"nullToken": "トークンなし",
|
"nullToken": "トークンなし",
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
"privateKey": "プライベートキー",
|
"privateKey": "プライベートキー",
|
||||||
"process": "プロセス",
|
"process": "プロセス",
|
||||||
"pushToken": "プッシュトークン",
|
"pushToken": "プッシュトークン",
|
||||||
|
"pveVersionLow": "この機能は現在テスト段階にあり、PVE 8+でのみテストされています。ご利用の際は慎重に。",
|
||||||
"pwd": "パスワード",
|
"pwd": "パスワード",
|
||||||
"read": "読み取り",
|
"read": "読み取り",
|
||||||
"reboot": "再起動",
|
"reboot": "再起動",
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
"noTask": "Sem tarefas",
|
"noTask": "Sem tarefas",
|
||||||
"noUpdateAvailable": "Sem atualizações disponíveis",
|
"noUpdateAvailable": "Sem atualizações disponíveis",
|
||||||
"node": "Nó",
|
"node": "Nó",
|
||||||
|
"notAvailable": "Indisponível",
|
||||||
"notSelected": "Não selecionado",
|
"notSelected": "Não selecionado",
|
||||||
"note": "Nota",
|
"note": "Nota",
|
||||||
"nullToken": "Token nulo",
|
"nullToken": "Token nulo",
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
"privateKey": "Chave privada",
|
"privateKey": "Chave privada",
|
||||||
"process": "Processo",
|
"process": "Processo",
|
||||||
"pushToken": "Token de notificação push",
|
"pushToken": "Token de notificação push",
|
||||||
|
"pveVersionLow": "Esta funcionalidade está atualmente em fase de teste e foi testada apenas no PVE 8+. Por favor, use com cautela.",
|
||||||
"pwd": "Senha",
|
"pwd": "Senha",
|
||||||
"read": "Leitura",
|
"read": "Leitura",
|
||||||
"reboot": "Reiniciar",
|
"reboot": "Reiniciar",
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
"noTask": "нет задач",
|
"noTask": "нет задач",
|
||||||
"noUpdateAvailable": "нет доступных обновлений",
|
"noUpdateAvailable": "нет доступных обновлений",
|
||||||
"node": "Узел",
|
"node": "Узел",
|
||||||
|
"notAvailable": "Недоступно",
|
||||||
"notSelected": "не выбрано",
|
"notSelected": "не выбрано",
|
||||||
"note": "заметка",
|
"note": "заметка",
|
||||||
"nullToken": "нет токена",
|
"nullToken": "нет токена",
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
"privateKey": "приватный ключ",
|
"privateKey": "приватный ключ",
|
||||||
"process": "процесс",
|
"process": "процесс",
|
||||||
"pushToken": "токен уведомлений",
|
"pushToken": "токен уведомлений",
|
||||||
|
"pveVersionLow": "Эта функция в настоящее время находится на стадии тестирования и была протестирована только на PVE 8+. Используйте ее с осторожностью.",
|
||||||
"pwd": "пароль",
|
"pwd": "пароль",
|
||||||
"read": "чтение",
|
"read": "чтение",
|
||||||
"reboot": "перезагрузка",
|
"reboot": "перезагрузка",
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
"noTask": "没有任务",
|
"noTask": "没有任务",
|
||||||
"noUpdateAvailable": "没有可用更新",
|
"noUpdateAvailable": "没有可用更新",
|
||||||
"node": "节点",
|
"node": "节点",
|
||||||
|
"notAvailable": "不可用",
|
||||||
"notSelected": "未选择",
|
"notSelected": "未选择",
|
||||||
"note": "备注",
|
"note": "备注",
|
||||||
"nullToken": "无Token",
|
"nullToken": "无Token",
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
"privateKey": "私钥",
|
"privateKey": "私钥",
|
||||||
"process": "进程",
|
"process": "进程",
|
||||||
"pushToken": "消息推送 Token",
|
"pushToken": "消息推送 Token",
|
||||||
|
"pveVersionLow": "当前该功能处于测试阶段,仅在PVE 8+上测试过,请谨慎使用",
|
||||||
"pwd": "密码",
|
"pwd": "密码",
|
||||||
"read": "读",
|
"read": "读",
|
||||||
"reboot": "重启",
|
"reboot": "重启",
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
"noTask": "沒有任務",
|
"noTask": "沒有任務",
|
||||||
"noUpdateAvailable": "沒有可用更新",
|
"noUpdateAvailable": "沒有可用更新",
|
||||||
"node": "節點",
|
"node": "節點",
|
||||||
|
"notAvailable": "不可用",
|
||||||
"notSelected": "未選擇",
|
"notSelected": "未選擇",
|
||||||
"note": "備註",
|
"note": "備註",
|
||||||
"nullToken": "無Token",
|
"nullToken": "無Token",
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
"privateKey": "私鑰",
|
"privateKey": "私鑰",
|
||||||
"process": "進程",
|
"process": "進程",
|
||||||
"pushToken": "消息推送 Token",
|
"pushToken": "消息推送 Token",
|
||||||
|
"pveVersionLow": "此功能目前處於測試階段,僅在PVE 8+上進行過測試。請謹慎使用。",
|
||||||
"pwd": "密碼",
|
"pwd": "密碼",
|
||||||
"read": "读",
|
"read": "读",
|
||||||
"reboot": "重启",
|
"reboot": "重启",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import 'package:toolbox/view/widget/appbar.dart';
|
|||||||
import 'package:toolbox/view/widget/icon_btn.dart';
|
import 'package:toolbox/view/widget/icon_btn.dart';
|
||||||
import 'package:toolbox/view/widget/kv_row.dart';
|
import 'package:toolbox/view/widget/kv_row.dart';
|
||||||
import 'package:toolbox/view/widget/percent_circle.dart';
|
import 'package:toolbox/view/widget/percent_circle.dart';
|
||||||
|
import 'package:toolbox/view/widget/row.dart';
|
||||||
import 'package:toolbox/view/widget/two_line_text.dart';
|
import 'package:toolbox/view/widget/two_line_text.dart';
|
||||||
|
|
||||||
final class PvePage extends StatefulWidget {
|
final class PvePage extends StatefulWidget {
|
||||||
@@ -48,6 +49,7 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_initRefreshTimer();
|
_initRefreshTimer();
|
||||||
|
_afterInit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -61,23 +63,46 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: CustomAppBar(
|
appBar: CustomAppBar(
|
||||||
title: TwoLineText(up: 'PVE', down: widget.spi.name),
|
title: TwoLineText(up: 'PVE', down: widget.spi.name),
|
||||||
|
actions: [
|
||||||
|
ValueListenableBuilder(
|
||||||
|
valueListenable: _pve.err,
|
||||||
|
builder: (_, val, __) => val == null
|
||||||
|
? UIs.placeholder
|
||||||
|
: IconBtn(
|
||||||
|
icon: Icons.refresh,
|
||||||
|
onTap: () {
|
||||||
|
_pve.err.value = null;
|
||||||
|
_pve.list();
|
||||||
|
_initRefreshTimer();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: ValueListenableBuilder(
|
body: ValueListenableBuilder(
|
||||||
|
valueListenable: _pve.err,
|
||||||
|
builder: (_, val, __) {
|
||||||
|
if (val != null) {
|
||||||
|
_timer?.cancel();
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(13),
|
||||||
|
child: Center(
|
||||||
|
child: Text(val),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ValueListenableBuilder(
|
||||||
valueListenable: _pve.data,
|
valueListenable: _pve.data,
|
||||||
builder: (_, val, __) {
|
builder: (_, val, __) {
|
||||||
return _buildBody(val);
|
return _buildBody(val);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody(PveRes? data) {
|
Widget _buildBody(PveRes? data) {
|
||||||
if (_pve.err.value != null) {
|
|
||||||
return Center(
|
|
||||||
child: Text('Failed to connect to PVE: ${_pve.err.value}'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return UIs.centerLoading;
|
return UIs.centerLoading;
|
||||||
}
|
}
|
||||||
@@ -113,6 +138,7 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
),
|
),
|
||||||
|
textAlign: TextAlign.start,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -193,9 +219,9 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQemu(PveQemu item) {
|
Widget _buildQemu(PveQemu item) {
|
||||||
if (!item.isRunning) {
|
if (!item.available) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(item.name, style: UIs.text13Bold),
|
title: Text(_wrapNodeName(item), style: UIs.text13Bold),
|
||||||
trailing: _buildCtrlBtns(item),
|
trailing: _buildCtrlBtns(item),
|
||||||
).card;
|
).card;
|
||||||
}
|
}
|
||||||
@@ -209,7 +235,7 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
children: [
|
children: [
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: item.name,
|
text: _wrapNodeName(item),
|
||||||
style: UIs.text13Bold,
|
style: UIs.text13Bold,
|
||||||
),
|
),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
@@ -225,12 +251,12 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
UIs.height7,
|
UIs.height7,
|
||||||
Row(
|
AvgWidthRow(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
width: _media.size.width,
|
||||||
|
padding: _kHorziPadding * 2 + 26,
|
||||||
children: [
|
children: [
|
||||||
_wrap(PercentCircle(percent: (item.cpu / item.maxcpu) * 100), 4),
|
PercentCircle(percent: (item.cpu / item.maxcpu) * 100),
|
||||||
_wrap(PercentCircle(percent: (item.mem / item.maxmem) * 100), 4),
|
PercentCircle(percent: (item.mem / item.maxmem) * 100),
|
||||||
_wrap(
|
|
||||||
Column(
|
Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -248,8 +274,6 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
4),
|
|
||||||
_wrap(
|
|
||||||
Column(
|
Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -267,7 +291,6 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
4),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 21)
|
const SizedBox(height: 21)
|
||||||
@@ -279,9 +302,9 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLxc(PveLxc item) {
|
Widget _buildLxc(PveLxc item) {
|
||||||
if (!item.isRunning) {
|
if (!item.available) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(item.name, style: UIs.text13Bold),
|
title: Text(_wrapNodeName(item), style: UIs.text13Bold),
|
||||||
trailing: _buildCtrlBtns(item),
|
trailing: _buildCtrlBtns(item),
|
||||||
).card;
|
).card;
|
||||||
}
|
}
|
||||||
@@ -295,7 +318,7 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
children: [
|
children: [
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: item.name,
|
text: _wrapNodeName(item),
|
||||||
style: UIs.text13Bold,
|
style: UIs.text13Bold,
|
||||||
),
|
),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
@@ -311,12 +334,12 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
UIs.height7,
|
UIs.height7,
|
||||||
Row(
|
AvgWidthRow(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
width: _media.size.width,
|
||||||
|
padding: _kHorziPadding * 2 + 26,
|
||||||
children: [
|
children: [
|
||||||
_wrap(PercentCircle(percent: (item.cpu / item.maxcpu) * 100), 4),
|
PercentCircle(percent: (item.cpu / item.maxcpu) * 100),
|
||||||
_wrap(PercentCircle(percent: (item.mem / item.maxmem) * 100), 4),
|
PercentCircle(percent: (item.mem / item.maxmem) * 100),
|
||||||
_wrap(
|
|
||||||
Column(
|
Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -334,8 +357,6 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
4),
|
|
||||||
_wrap(
|
|
||||||
Column(
|
Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -353,7 +374,6 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
4),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 21)
|
const SizedBox(height: 21)
|
||||||
@@ -369,12 +389,13 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
padding: const EdgeInsets.all(13),
|
padding: const EdgeInsets.all(13),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(item.storage, style: UIs.text13Bold),
|
Text(_wrapNodeName(item), style: UIs.text13Bold),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(item.status, style: UIs.text12Grey),
|
Text(item.summary, style: UIs.text11Grey),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
UIs.height7,
|
UIs.height7,
|
||||||
@@ -387,90 +408,41 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
|
|
||||||
Widget _buildSdn(PveSdn item) {
|
Widget _buildSdn(PveSdn item) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(item.sdn),
|
title: Text(_wrapNodeName(item)),
|
||||||
trailing: Text(item.status),
|
trailing: Text(item.summary),
|
||||||
).card;
|
).card;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCtrlBtns(PveCtrlIface item) {
|
Widget _buildCtrlBtns(PveCtrlIface item) {
|
||||||
if (!item.isRunning) {
|
if (!item.available) {
|
||||||
return IconBtn(
|
return IconBtn(
|
||||||
icon: Icons.play_arrow,
|
icon: Icons.play_arrow,
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
onTap: () async {
|
onTap: () => _onCtrl(_pve.start, l10n.start, item));
|
||||||
bool? suc;
|
|
||||||
await context.showLoadingDialog(fn: () async {
|
|
||||||
suc = await _pve.start(item.node, item.id);
|
|
||||||
});
|
|
||||||
if (suc == true) {
|
|
||||||
context.showSnackBar(l10n.success);
|
|
||||||
} else {
|
|
||||||
context.showSnackBar(l10n.failed);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
IconBtn(
|
IconBtn(
|
||||||
icon: Icons.stop,
|
icon: Icons.stop,
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
onTap: () async {
|
onTap: () => _onCtrl(_pve.stop, l10n.stop, item)),
|
||||||
final sure = await _ask(l10n.stop, item.id);
|
|
||||||
if (!sure) return;
|
|
||||||
bool? suc;
|
|
||||||
await context.showLoadingDialog(fn: () async {
|
|
||||||
suc = await _pve.stop(item.node, item.id);
|
|
||||||
});
|
|
||||||
if (suc == true) {
|
|
||||||
context.showSnackBar(l10n.success);
|
|
||||||
} else {
|
|
||||||
context.showSnackBar(l10n.failed);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconBtn(
|
IconBtn(
|
||||||
icon: Icons.refresh,
|
icon: Icons.refresh,
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
onTap: () async {
|
onTap: () => _onCtrl(_pve.reboot, l10n.reboot, item)),
|
||||||
final sure = await _ask(l10n.reboot, item.id);
|
|
||||||
if (!sure) return;
|
|
||||||
bool? suc;
|
|
||||||
await context.showLoadingDialog(fn: () async {
|
|
||||||
suc = await _pve.reboot(item.node, item.id);
|
|
||||||
});
|
|
||||||
if (suc == true) {
|
|
||||||
context.showSnackBar(l10n.success);
|
|
||||||
} else {
|
|
||||||
context.showSnackBar(l10n.failed);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconBtn(
|
IconBtn(
|
||||||
icon: Icons.power_off,
|
icon: Icons.power_off,
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
onTap: () async {
|
onTap: () => _onCtrl(_pve.shutdown, l10n.shutdown, item),
|
||||||
final sure = await _ask(l10n.shutdown, item.id);
|
|
||||||
if (!sure) return;
|
|
||||||
bool? suc;
|
|
||||||
await context.showLoadingDialog(fn: () async {
|
|
||||||
suc = await _pve.shutdown(item.node, item.id);
|
|
||||||
});
|
|
||||||
if (suc == true) {
|
|
||||||
context.showSnackBar(l10n.success);
|
|
||||||
} else {
|
|
||||||
context.showSnackBar(l10n.failed);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _ask(String action, String id) async {
|
void _onCtrl(PveCtrlFunc func, String action, PveCtrlIface item) async {
|
||||||
final sure = await context.showRoundDialog(
|
final sure = await context.showRoundDialog<bool>(
|
||||||
title: Text(l10n.attention),
|
title: Text(l10n.attention),
|
||||||
child: Text(l10n.askContinue('$action $id')),
|
child: Text(l10n.askContinue('$action ${item.id}')),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.pop(true),
|
onPressed: () => context.pop(true),
|
||||||
@@ -478,14 +450,24 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return sure == true;
|
if (sure != true) return;
|
||||||
|
bool? suc;
|
||||||
|
await context.showLoadingDialog(fn: () async {
|
||||||
|
suc = await func(item.node, item.id);
|
||||||
|
});
|
||||||
|
if (suc == true) {
|
||||||
|
context.showSnackBar(l10n.success);
|
||||||
|
} else {
|
||||||
|
context.showSnackBar(l10n.failed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _wrap(Widget child, int count) {
|
/// Add PveNode if [PveProvider.onlyOneNode] is false
|
||||||
return SizedBox(
|
String _wrapNodeName(PveCtrlIface item) {
|
||||||
width: (_media.size.width - 2 * _kHorziPadding - 26) / count,
|
if (_pve.onlyOneNode) {
|
||||||
child: child,
|
return item.name;
|
||||||
);
|
}
|
||||||
|
return '${item.node} / ${item.name}';
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initRefreshTimer() {
|
void _initRefreshTimer() {
|
||||||
@@ -497,4 +479,13 @@ final class _PvePageState extends State<PvePage> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _afterInit() async {
|
||||||
|
await _pve.connected.future;
|
||||||
|
if (_pve.release != null && _pve.release!.compareTo('8.0') < 0) {
|
||||||
|
if (mounted) {
|
||||||
|
context.showSnackBar(l10n.pveVersionLow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
lib/view/widget/row.dart
Normal file
25
lib/view/widget/row.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
final class AvgWidthRow extends StatelessWidget {
|
||||||
|
final List<Widget> children;
|
||||||
|
final double? width;
|
||||||
|
final double padding;
|
||||||
|
|
||||||
|
const AvgWidthRow({
|
||||||
|
super.key,
|
||||||
|
required this.children,
|
||||||
|
this.width,
|
||||||
|
this.padding = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final width =
|
||||||
|
((this.width ?? MediaQuery.of(context).size.width) - padding) /
|
||||||
|
children.length;
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: children.map((e) => SizedBox(width: width, child: e)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user