opt.: pve dashboard (#307)

This commit is contained in:
lollipopkit
2024-03-19 01:26:53 -06:00
parent 48fdf4cc84
commit 7edef87a4f
14 changed files with 338 additions and 240 deletions

View File

@@ -1,6 +1,7 @@
import 'package:toolbox/core/extension/context/locale.dart';
import 'package:toolbox/core/extension/duration.dart';
import 'package:toolbox/core/extension/numx.dart';
import 'package:toolbox/core/extension/order.dart';
enum PveResType {
lxc,
@@ -61,8 +62,9 @@ sealed class PveResIface {
abstract interface class PveCtrlIface {
String get node;
String get id;
bool get isRunning;
bool get available;
String get summary;
String get name;
}
final class PveLxc extends PveResIface implements PveCtrlIface {
@@ -73,6 +75,7 @@ final class PveLxc extends PveResIface implements PveCtrlIface {
final int vmid;
@override
final String node;
@override
final String name;
@override
final String status;
@@ -131,11 +134,11 @@ final class PveLxc extends PveResIface implements PveCtrlIface {
}
@override
bool get isRunning => status == 'running';
bool get available => status == 'running';
@override
String get summary {
if (isRunning) {
if (available) {
return uptime.secondsToDuration().toStr;
}
return l10n.stopped;
@@ -150,6 +153,7 @@ final class PveQemu extends PveResIface implements PveCtrlIface {
final int vmid;
@override
final String node;
@override
final String name;
@override
final String status;
@@ -208,11 +212,11 @@ final class PveQemu extends PveResIface implements PveCtrlIface {
}
@override
bool get isRunning => status == 'running';
bool get available => status == 'running';
@override
String get summary {
if (isRunning) {
if (available) {
return uptime.secondsToDuration().toStr;
}
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
final String id;
@override
final PveResType type;
final String storage;
@override
final String node;
@override
final String status;
@@ -311,14 +316,29 @@ final class PveStorage extends PveResIface {
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
final String id;
@override
final PveResType type;
final String sdn;
@override
final String node;
@override
final String status;
@@ -340,6 +360,15 @@ final class PveSdn extends PveResIface {
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 {
@@ -357,6 +386,8 @@ final class PveRes {
required this.sdns,
});
bool get onlyOneNode => nodes.length == 1;
int get length =>
qemus.length + lxcs.length + nodes.length + storages.length + sdns.length;
@@ -379,4 +410,59 @@ final class PveRes {
index -= storages.length;
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,
);
}
}

View File

@@ -1,21 +1,20 @@
import 'dart:async';
import 'package:computer/computer.dart';
import 'package:dio/dio.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/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 ServerPrivateInfo spi;
late final String addr;
//late final SSHClient _client;
final data = ValueNotifier<PveRes?>(null);
PveProvider({
required this.spi,
}) {
PveProvider({required this.spi}) {
// final client = _spi.server?.client;
// if (client == null) {
// throw Exception('Server client is null');
@@ -27,19 +26,31 @@ final class PveProvider extends ChangeNotifier {
return;
}
this.addr = addr;
_init().then((_) => connected.complete());
_init();
}
final err = ValueNotifier<String?>(null);
final connected = Completer<void>();
final session = Dio();
final data = ValueNotifier<PveRes?>(null);
bool get onlyOneNode => data.value?.nodes.length == 1;
String? release;
bool isBusy = false;
// int _localPort = 0;
// String get addr => 'http://127.0.0.1:$_localPort';
Future<void> _init() async {
try {
//await _forward();
await _login();
await _release;
} catch (e) {
Loggers.app.warning('PVE init failed', e);
err.value = e.toString();
} finally {
connected.complete();
}
}
// Future<void> _forward() async {
@@ -75,67 +86,32 @@ final class PveProvider extends ChangeNotifier {
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;
if (isBusy) return;
isBusy = true;
try {
final resp = await session.get('$addr/api2/json/cluster/resources');
final list = resp.data['data'] as List;
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;
final res = resp.data['data'] as List;
final result = await Computer.shared.start(PveRes.parse, (res, data.value));
data.value = result;
} catch (e) {
Loggers.app.warning('PVE list failed', e);
err.value = e.toString();
} finally {
isBusy = false;
}
}
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 {
await connected.future;
final resp =

View File

@@ -167,6 +167,7 @@
"noTask": "Nicht fragen",
"noUpdateAvailable": "Kein Update verfügbar",
"node": "Knoten",
"notAvailable": "Nicht verfügbar",
"notSelected": "Nicht ausgewählt",
"note": "Hinweis",
"nullToken": "Null token",
@@ -197,6 +198,7 @@
"privateKey": "Private Key",
"process": "Prozess",
"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",
"read": "Lesen",
"reboot": "Neustart",

View File

@@ -167,6 +167,7 @@
"noTask": "No task",
"noUpdateAvailable": "No update available",
"node": "Node",
"notAvailable": "Unavailable",
"notSelected": "Not selected",
"note": "Note",
"nullToken": "Null token",
@@ -197,6 +198,7 @@
"privateKey": "Private Key",
"process": "Process",
"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",
"read": "Read",
"reboot": "Reboot",

View File

@@ -167,6 +167,7 @@
"noTask": "Sin tareas",
"noUpdateAvailable": "No hay actualizaciones disponibles",
"node": "Nodo",
"notAvailable": "No disponible",
"notSelected": "No seleccionado",
"note": "Nota",
"nullToken": "Token nulo",
@@ -197,6 +198,7 @@
"privateKey": "Llave privada",
"process": "Proceso",
"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",
"read": "Leer",
"reboot": "Reiniciar",

View File

@@ -167,6 +167,7 @@
"noTask": "Aucune tâche",
"noUpdateAvailable": "Aucune mise à jour disponible",
"node": "Noeud",
"notAvailable": "Non disponible",
"notSelected": "Non sélectionné",
"note": "Note",
"nullToken": "Jeton nul",
@@ -197,6 +198,7 @@
"privateKey": "Clé privée",
"process": "Processus",
"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",
"read": "Lire",
"reboot": "Redémarrer",

View File

@@ -167,6 +167,7 @@
"noTask": "Tidak bertanya",
"noUpdateAvailable": "Tidak ada pembaruan yang tersedia",
"node": "Node",
"notAvailable": "Tidak tersedia",
"notSelected": "Tidak terpilih",
"note": "Catatan",
"nullToken": "Token NULL",
@@ -197,6 +198,7 @@
"privateKey": "Kunci Pribadi",
"process": "Proses",
"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",
"read": "Baca",
"reboot": "Reboot",

View File

@@ -167,6 +167,7 @@
"noTask": "タスクがありません",
"noUpdateAvailable": "利用可能な更新はありません",
"node": "ノード",
"notAvailable": "利用不可",
"notSelected": "選択されていません",
"note": "メモ",
"nullToken": "トークンなし",
@@ -197,6 +198,7 @@
"privateKey": "プライベートキー",
"process": "プロセス",
"pushToken": "プッシュトークン",
"pveVersionLow": "この機能は現在テスト段階にあり、PVE 8+でのみテストされています。ご利用の際は慎重に。",
"pwd": "パスワード",
"read": "読み取り",
"reboot": "再起動",

View File

@@ -167,6 +167,7 @@
"noTask": "Sem tarefas",
"noUpdateAvailable": "Sem atualizações disponíveis",
"node": "Nó",
"notAvailable": "Indisponível",
"notSelected": "Não selecionado",
"note": "Nota",
"nullToken": "Token nulo",
@@ -197,6 +198,7 @@
"privateKey": "Chave privada",
"process": "Processo",
"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",
"read": "Leitura",
"reboot": "Reiniciar",

View File

@@ -167,6 +167,7 @@
"noTask": "нет задач",
"noUpdateAvailable": "нет доступных обновлений",
"node": "Узел",
"notAvailable": "Недоступно",
"notSelected": "не выбрано",
"note": "заметка",
"nullToken": "нет токена",
@@ -197,6 +198,7 @@
"privateKey": "приватный ключ",
"process": "процесс",
"pushToken": "токен уведомлений",
"pveVersionLow": "Эта функция в настоящее время находится на стадии тестирования и была протестирована только на PVE 8+. Используйте ее с осторожностью.",
"pwd": "пароль",
"read": "чтение",
"reboot": "перезагрузка",

View File

@@ -167,6 +167,7 @@
"noTask": "没有任务",
"noUpdateAvailable": "没有可用更新",
"node": "节点",
"notAvailable": "不可用",
"notSelected": "未选择",
"note": "备注",
"nullToken": "无Token",
@@ -197,6 +198,7 @@
"privateKey": "私钥",
"process": "进程",
"pushToken": "消息推送 Token",
"pveVersionLow": "当前该功能处于测试阶段仅在PVE 8+上测试过,请谨慎使用",
"pwd": "密码",
"read": "读",
"reboot": "重启",

View File

@@ -167,6 +167,7 @@
"noTask": "沒有任務",
"noUpdateAvailable": "沒有可用更新",
"node": "節點",
"notAvailable": "不可用",
"notSelected": "未選擇",
"note": "備註",
"nullToken": "無Token",
@@ -197,6 +198,7 @@
"privateKey": "私鑰",
"process": "進程",
"pushToken": "消息推送 Token",
"pveVersionLow": "此功能目前處於測試階段僅在PVE 8+上進行過測試。請謹慎使用。",
"pwd": "密碼",
"read": "读",
"reboot": "重启",

View File

@@ -17,6 +17,7 @@ import 'package:toolbox/view/widget/appbar.dart';
import 'package:toolbox/view/widget/icon_btn.dart';
import 'package:toolbox/view/widget/kv_row.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';
final class PvePage extends StatefulWidget {
@@ -48,6 +49,7 @@ final class _PvePageState extends State<PvePage> {
void initState() {
super.initState();
_initRefreshTimer();
_afterInit();
}
@override
@@ -61,23 +63,46 @@ final class _PvePageState extends State<PvePage> {
return Scaffold(
appBar: CustomAppBar(
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(
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,
builder: (_, val, __) {
return _buildBody(val);
},
);
},
),
);
}
Widget _buildBody(PveRes? data) {
if (_pve.err.value != null) {
return Center(
child: Text('Failed to connect to PVE: ${_pve.err.value}'),
);
}
if (data == null) {
return UIs.centerLoading;
}
@@ -113,6 +138,7 @@ final class _PvePageState extends State<PvePage> {
fontWeight: FontWeight.bold,
color: Colors.grey,
),
textAlign: TextAlign.start,
),
),
);
@@ -193,9 +219,9 @@ final class _PvePageState extends State<PvePage> {
}
Widget _buildQemu(PveQemu item) {
if (!item.isRunning) {
if (!item.available) {
return ListTile(
title: Text(item.name, style: UIs.text13Bold),
title: Text(_wrapNodeName(item), style: UIs.text13Bold),
trailing: _buildCtrlBtns(item),
).card;
}
@@ -209,7 +235,7 @@ final class _PvePageState extends State<PvePage> {
text: TextSpan(
children: [
TextSpan(
text: item.name,
text: _wrapNodeName(item),
style: UIs.text13Bold,
),
TextSpan(
@@ -225,12 +251,12 @@ final class _PvePageState extends State<PvePage> {
],
),
UIs.height7,
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
AvgWidthRow(
width: _media.size.width,
padding: _kHorziPadding * 2 + 26,
children: [
_wrap(PercentCircle(percent: (item.cpu / item.maxcpu) * 100), 4),
_wrap(PercentCircle(percent: (item.mem / item.maxmem) * 100), 4),
_wrap(
PercentCircle(percent: (item.cpu / item.maxcpu) * 100),
PercentCircle(percent: (item.mem / item.maxmem) * 100),
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
@@ -248,8 +274,6 @@ final class _PvePageState extends State<PvePage> {
)
],
),
4),
_wrap(
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
@@ -267,7 +291,6 @@ final class _PvePageState extends State<PvePage> {
)
],
),
4),
],
),
const SizedBox(height: 21)
@@ -279,9 +302,9 @@ final class _PvePageState extends State<PvePage> {
}
Widget _buildLxc(PveLxc item) {
if (!item.isRunning) {
if (!item.available) {
return ListTile(
title: Text(item.name, style: UIs.text13Bold),
title: Text(_wrapNodeName(item), style: UIs.text13Bold),
trailing: _buildCtrlBtns(item),
).card;
}
@@ -295,7 +318,7 @@ final class _PvePageState extends State<PvePage> {
text: TextSpan(
children: [
TextSpan(
text: item.name,
text: _wrapNodeName(item),
style: UIs.text13Bold,
),
TextSpan(
@@ -311,12 +334,12 @@ final class _PvePageState extends State<PvePage> {
],
),
UIs.height7,
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
AvgWidthRow(
width: _media.size.width,
padding: _kHorziPadding * 2 + 26,
children: [
_wrap(PercentCircle(percent: (item.cpu / item.maxcpu) * 100), 4),
_wrap(PercentCircle(percent: (item.mem / item.maxmem) * 100), 4),
_wrap(
PercentCircle(percent: (item.cpu / item.maxcpu) * 100),
PercentCircle(percent: (item.mem / item.maxmem) * 100),
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
@@ -334,8 +357,6 @@ final class _PvePageState extends State<PvePage> {
)
],
),
4),
_wrap(
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
@@ -353,7 +374,6 @@ final class _PvePageState extends State<PvePage> {
)
],
),
4),
],
),
const SizedBox(height: 21)
@@ -369,12 +389,13 @@ final class _PvePageState extends State<PvePage> {
padding: const EdgeInsets.all(13),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(item.storage, style: UIs.text13Bold),
Text(_wrapNodeName(item), style: UIs.text13Bold),
const Spacer(),
Text(item.status, style: UIs.text12Grey),
Text(item.summary, style: UIs.text11Grey),
],
),
UIs.height7,
@@ -387,90 +408,41 @@ final class _PvePageState extends State<PvePage> {
Widget _buildSdn(PveSdn item) {
return ListTile(
title: Text(item.sdn),
trailing: Text(item.status),
title: Text(_wrapNodeName(item)),
trailing: Text(item.summary),
).card;
}
Widget _buildCtrlBtns(PveCtrlIface item) {
if (!item.isRunning) {
if (!item.available) {
return IconBtn(
icon: Icons.play_arrow,
color: Colors.grey,
onTap: () async {
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);
}
},
);
onTap: () => _onCtrl(_pve.start, l10n.start, item));
}
return Row(
children: [
IconBtn(
icon: Icons.stop,
color: Colors.grey,
onTap: () async {
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);
}
},
),
onTap: () => _onCtrl(_pve.stop, l10n.stop, item)),
IconBtn(
icon: Icons.refresh,
color: Colors.grey,
onTap: () async {
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);
}
},
),
onTap: () => _onCtrl(_pve.reboot, l10n.reboot, item)),
IconBtn(
icon: Icons.power_off,
color: Colors.grey,
onTap: () async {
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);
}
},
onTap: () => _onCtrl(_pve.shutdown, l10n.shutdown, item),
),
],
);
}
Future<bool> _ask(String action, String id) async {
final sure = await context.showRoundDialog(
void _onCtrl(PveCtrlFunc func, String action, PveCtrlIface item) async {
final sure = await context.showRoundDialog<bool>(
title: Text(l10n.attention),
child: Text(l10n.askContinue('$action $id')),
child: Text(l10n.askContinue('$action ${item.id}')),
actions: [
TextButton(
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) {
return SizedBox(
width: (_media.size.width - 2 * _kHorziPadding - 26) / count,
child: child,
);
/// Add PveNode if [PveProvider.onlyOneNode] is false
String _wrapNodeName(PveCtrlIface item) {
if (_pve.onlyOneNode) {
return item.name;
}
return '${item.node} / ${item.name}';
}
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
View 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(),
);
}
}