Files
flutter_server_box/lib/view/page/server/detail/view.dart
GT610 9ac866644c ref:Refactor Settings UI and Fix Performance Issues (#1026)
* refactor(Settings page): Simplify the click handling logic of the cancel button

* fix(backup_service): Add a cancel button in the restore backup dialog

* refactor(Settings Page): Refactor the ordered list component and optimize state management

- Extract the logic for building list items into a separate method to improve maintainability
- Add animation effects to enhance the dragging experience
- Use PageStorageKey to maintain the scroll position
- Optimize the state management logic of the checkbox
- Add new contributors in github_id.dart

* fix: Add SafeArea to the settings page to prevent content from being obscured

Add SafeArea wrapping content in multiple settings pages to prevent content from being obscured by the navigation bar on certain devices, thereby enhancing user experience

* refactor: Extract file list retrieval method and optimize asynchronous loading of iOS settings page

Extract the `_getEntities` method from an inline function to a class member method to enhance code readability

Preload watch context and push token in the iOS settings page to avoid repeatedly creating Futures

* fix: Add a `key` attribute to the ChoiceChipX component to avoid rendering issues

* refactor(Settings page): Refactor the platform-related settings logic and merge the Android settings into the main page

Migrate the Android platform settings from a standalone page to the main settings page, and remove redundant Android settings page files

Adjust the platform setting logic, retaining only the special setting entry for the iOS platform

* build: Update fl_lib dependency to v1.0.363

* feat(Settings): Add persistent disable state for cards and virtual keys

Add persistent storage functionality for server detail cards and SSH virtual key disable status

Modify the logic of relevant pages to support the saving and restoration of disabled states

* refactor(setting): Simplify save logic and optimize file sorting performance

In the settings page, remove the unnecessary `enabledList` filtering and directly save the `_order` list

Optimize the sorting logic on the local file page by first retrieving the file status before proceeding with sorting

* fix: Optimize data filtering and backup service error handling on the settings page

Fix the data filtering logic in the settings page to only process key-value pairs with specific prefixes
Add error handling to the backup service, capture and display merge failure exceptions

* fix(Settings page): Fixed the issue where disabled items were not included in the order settings and asynchronously saved preference settings

Fix the issue where disabled items in the virtual keyboard and service details order settings are not included in the order list

Change the preference setting saving method to an asynchronous operation, and add a mounted check to prevent updating the state after the component is unmounted

* refactor: Optimize the reordering logic and remove redundant sorting methods

Narrow the scope of state updates in the reordering logic to only encompass the parts where data is actually modified

Remove the unused sorting methods in `_local.dart` to simplify the code

* refactor(view): Optimize the refresh logic of the local file page

Refactor the refresh method that directly calls setState into a unified _refresh method

Use the `_entitiesFuture` to cache the list of files to obtain results and avoid redundant calculations

* Update lib/view/page/storage/local.dart

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-01-25 18:43:57 +08:00

1027 lines
31 KiB
Dart

import 'package:extended_image/extended_image.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/extension/server.dart';
import 'package:server_box/core/route.dart';
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
import 'package:server_box/data/model/app/server_detail_card.dart';
import 'package:server_box/data/model/server/amd.dart';
import 'package:server_box/data/model/server/battery.dart';
import 'package:server_box/data/model/server/cpu.dart';
import 'package:server_box/data/model/server/disk.dart';
import 'package:server_box/data/model/server/disk_smart.dart';
import 'package:server_box/data/model/server/net_speed.dart';
import 'package:server_box/data/model/server/nvdia.dart';
import 'package:server_box/data/model/server/sensors.dart';
import 'package:server_box/data/model/server/server.dart' as server_model;
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/pve.dart';
import 'package:server_box/view/page/server/edit/edit.dart';
import 'package:server_box/view/widget/server_func_btns.dart';
part 'misc.dart';
class ServerDetailPage extends ConsumerStatefulWidget {
final SpiRequiredArgs args;
const ServerDetailPage({super.key, required this.args});
@override
ConsumerState<ServerDetailPage> createState() => _ServerDetailPageState();
static const route = AppRouteArg(page: ServerDetailPage.new, path: '/servers/detail');
}
class _ServerDetailPageState extends ConsumerState<ServerDetailPage> with SingleTickerProviderStateMixin {
late final _cardBuildMap = Map.fromIterables(ServerDetailCards.names, [
_buildAbout,
_buildCPUView,
_buildMemView,
_buildSwapView,
_buildGpuView,
_buildDiskView,
_buildDiskSmart,
_buildNetView,
_buildSensors,
_buildTemperature,
_buildBatteries,
_buildPve,
_buildCustomCmd,
]);
late Size _size;
final List<String> _cardsOrder = [];
final _settings = Stores.setting;
final _netSortType = ValueNotifier(_NetSortType.device);
late final _collapse = _settings.collapseUIDefault.fetch();
late final _textFactor = TextScaler.linear(_settings.textFactor.fetch());
late final _cpuViewAsProgress = _settings.cpuViewAsProgress.fetch();
late final _moveServerFuncs = _settings.moveServerFuncs.fetch();
late final _displayCpuIndex = _settings.displayCpuIndex.fetch();
@override
void dispose() {
super.dispose();
_netSortType.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_size = MediaQuery.sizeOf(context);
}
@override
void initState() {
super.initState();
final order = _settings.detailCardOrder.fetch();
final disabled = _settings.detailCardDisabled.fetch();
order.removeWhere((e) => !ServerDetailCards.names.contains(e) || disabled.contains(e));
_cardsOrder.addAll(order);
}
@override
Widget build(BuildContext context) {
final serverState = ref.watch(serverProvider(widget.args.spi.id));
if (serverState.client == null) {
return Scaffold(
appBar: CustomAppBar(),
body: Center(child: Text(libL10n.empty)),
);
}
return _buildMainPage(serverState);
}
Widget _buildMainPage(ServerState si) {
final buildFuncs = !_moveServerFuncs;
final logo = _buildLogo(si);
final children = <Widget>[if (logo != null) logo, if (buildFuncs) ServerFuncBtns(spi: si.spi)];
for (final card in _cardsOrder) {
final child = _cardBuildMap[card]?.call(si);
if (child != null) {
children.add(child);
}
}
return Scaffold(
appBar: _buildAppBar(si),
body: SafeArea(child: AutoMultiList(children: children)),
);
}
CustomAppBar _buildAppBar(ServerState si) {
return CustomAppBar(
title: Text(
si.spi.name,
style: TextStyle(fontSize: 20, color: context.isDark ? Colors.white : Colors.black),
),
actions: [
QrShareBtn(data: si.spi.toJsonString(), tip: si.spi.name, tip2: '${l10n.server} ~ ServerBox'),
IconButton(
icon: const Icon(Icons.edit),
onPressed: () async {
final delete = await ServerEditPage.route.go(context, args: SpiRequiredArgs(si.spi));
if (delete == true) {
context.pop();
}
},
),
],
);
}
Widget? _buildLogo(ServerState si) {
final logoUrl = si.getLogoUrl(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: LayoutBuilder(
builder: (_, cons) {
if (logoUrl == null) {
return UIs.placeholder;
}
return ExtendedImage.network(
logoUrl,
cache: true,
height: cons.maxWidth * 0.3,
width: cons.maxWidth,
);
},
),
);
}
Widget? _buildAbout(ServerState si) {
final ss = si.status;
return ExpandTile(
key: ValueKey(ss.more.hashCode), // Use hashCode to avoid perf issue
leading: const Icon(MingCute.information_fill, size: 20),
initiallyExpanded: _getInitExpand(ss.more.entries.length),
title: Text(libL10n.about),
childrenPadding: const EdgeInsets.symmetric(horizontal: 17, vertical: 11),
children: ss.more.entries
.map(
(e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(e.key.i18n, style: UIs.text13, overflow: TextOverflow.ellipsis),
Text(e.value, style: UIs.text13Grey, overflow: TextOverflow.ellipsis),
],
),
),
)
.toList(),
).cardx;
}
Widget? _buildCPUView(ServerState si) {
final ss = si.status;
final percent = ss.cpu.usedPercent(coreIdx: 0).toInt();
final details = [
_buildDetailPercent(ss.cpu.user, 'user'),
UIs.width13,
_buildDetailPercent(ss.cpu.idle, 'idle'),
];
if (ss.system == SystemType.linux) {
details.addAll([
UIs.width13,
_buildDetailPercent(ss.cpu.sys, 'sys'),
UIs.width13,
_buildDetailPercent(ss.cpu.iowait, 'io'),
]);
}
final List<Widget> children = _cpuViewAsProgress
? _buildCPUProgress(ss.cpu)
: [_buildCPUChart(ss)];
if (ss.cpu.brand.isNotEmpty) {
children.add(
Column(children: ss.cpu.brand.entries.map(_buildCpuModelItem).toList()).paddingOnly(top: 13),
);
}
return ExpandTile(
title: Align(
alignment: Alignment.centerLeft,
child: _buildAnimatedText(ValueKey(percent), '$percent%', UIs.text27),
),
childrenPadding: const EdgeInsets.symmetric(vertical: 13),
initiallyExpanded: _getInitExpand(1),
trailing: Row(mainAxisSize: MainAxisSize.min, children: details),
children: children,
).cardx;
}
Widget _buildCpuModelItem(MapEntry<String, int> e) {
final name = e.key
.replaceFirst('Intel(R)', '')
.replaceFirst('AMD', '')
.replaceFirst('with Radeon Graphics', '');
final child = Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
LayoutBuilder(
builder: (_, cons) {
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: cons.maxWidth * .7),
child: Text(name, style: UIs.text13, overflow: TextOverflow.ellipsis, maxLines: 1),
);
},
),
Text('x ${e.value}', style: UIs.text13Grey, overflow: TextOverflow.clip),
],
);
return child.paddingSymmetric(horizontal: 17);
}
Widget _buildDetailPercent(double percent, String timeType) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${percent.toStringAsFixed(1)}%', style: UIs.text12, textScaler: _textFactor),
Text(timeType, style: UIs.text12Grey, textScaler: _textFactor),
],
);
}
List<Widget> _buildCPUProgress(Cpus cs) {
const kMaxColumn = 2;
const kRowThreshold = 4;
const kCoresCountThreshold = kMaxColumn * kRowThreshold;
final children = <Widget>[];
final displayCpuIndexSetting = _displayCpuIndex;
if (cs.coresCount > kCoresCountThreshold) {
final numCoresToDisplay = cs.coresCount - 1;
final numRows = (numCoresToDisplay + kMaxColumn - 1) ~/ kMaxColumn;
for (var i = 0; i < numRows; i++) {
final rowChildren = <Widget>[];
for (var j = 0; j < kMaxColumn; j++) {
final coreListIndex = i * kMaxColumn + j;
if (coreListIndex >= numCoresToDisplay) break;
final coreNumberOneBased = coreListIndex + 1;
if (displayCpuIndexSetting) {
rowChildren.add(Text('$coreNumberOneBased', style: UIs.text13Grey));
}
rowChildren.add(
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: _buildProgress(cs.usedPercent(coreIdx: coreNumberOneBased)),
),
),
);
}
if (rowChildren.isNotEmpty) {
children.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 17),
child: Row(children: rowChildren.joinWith(UIs.width7).toList()),
),
);
}
}
} else {
for (var i = 1; i < cs.coresCount; i++) {
children.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 17),
child: _buildProgress(cs.usedPercent(coreIdx: i)),
),
);
}
}
return children;
}
Widget _buildCPUChart(server_model.ServerStatus ss) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 13),
child: LayoutBuilder(
builder: (_, cons) {
return SizedBox(
height: 137,
width: cons.maxWidth,
child: _buildLineChart(
ss.cpu.spots,
//ss.cpu.rangeX,
tooltipPrefix: 'CPU',
),
);
},
),
);
}
Widget _buildProgress(double percent) {
if (percent > 100) percent = 100;
final percentWithinOne = percent / 100;
return LinearProgressIndicator(
value: percentWithinOne,
minHeight: 7,
backgroundColor: UIs.halfAlpha,
color: UIs.primaryColor,
);
}
Widget? _buildMemView(ServerState si) {
final ss = si.status;
final free = ss.mem.free / ss.mem.total * 100;
final avail = ss.mem.availPercent * 100;
final used = ss.mem.usedPercent * 100;
final usedStr = used.toStringAsFixed(0);
final percentW = Row(
children: [
_buildAnimatedText(ValueKey(usedStr), '$usedStr%', UIs.text27),
UIs.width7,
Text('of ${(ss.mem.total * 1024).bytes2Str}', style: UIs.text13Grey),
],
);
return Padding(
padding: UIs.roundRectCardPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
percentW,
Row(
children: [
_buildDetailPercent(free, 'free'),
UIs.width13,
_buildDetailPercent(avail, 'avail'),
],
),
],
),
UIs.height13,
_buildProgress(used),
],
),
).cardx;
}
Widget? _buildSwapView(ServerState si) {
final ss = si.status;
if (ss.swap.total == 0) return null;
final used = ss.swap.usedPercent * 100;
final cached = ss.swap.cached / ss.swap.total * 100;
final percentW = Row(
children: [
Text('${used.toStringAsFixed(0)}%', style: UIs.text27),
UIs.width7,
Text('of ${(ss.swap.total * 1024).bytes2Str} ', style: UIs.text13Grey),
],
);
return Padding(
padding: UIs.roundRectCardPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [percentW, _buildDetailPercent(cached, 'cached')],
),
UIs.height13,
_buildProgress(used),
],
),
).cardx;
}
Widget? _buildGpuView(ServerState si) {
final ss = si.status;
final hasNvidia = ss.nvidia != null && ss.nvidia!.isNotEmpty;
final hasAmd = ss.amd != null && ss.amd!.isNotEmpty;
if (!hasNvidia && !hasAmd) return null;
final children = <Widget>[];
// Add NVIDIA GPUs
if (hasNvidia) {
children.addAll(ss.nvidia!.map((e) => _buildNvidiaGpuItem(e)));
}
// Add AMD GPUs
if (hasAmd) {
children.addAll(ss.amd!.map((e) => _buildAmdGpuItem(e)));
}
return ExpandTile(
title: const Text('GPU'),
leading: const Icon(Icons.memory, size: 17),
initiallyExpanded: _getInitExpand(children.length, 3),
children: children,
).cardx;
}
Widget _buildNvidiaGpuItem(NvidiaSmiItem item) {
final mem = item.memory;
return ListTile(
title: Text(item.name, style: UIs.text13),
leading: Text(
'${item.percent}%\n${item.temp} °C',
style: UIs.text12Grey,
textScaler: _textFactor,
textAlign: TextAlign.center,
),
subtitle: Text(
'${item.power} - FAN ${item.fanSpeed}%\n${mem.used} / ${mem.total} ${mem.unit}',
style: UIs.text12Grey,
textScaler: _textFactor,
),
contentPadding: const EdgeInsets.only(left: 17, right: 17),
trailing: Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => _onTapNvidiaGpuItem(item),
icon: const Icon(Icons.info_outline, size: 17),
),
],
),
);
}
Widget _buildAmdGpuItem(AmdSmiItem item) {
final mem = item.memory;
return ListTile(
title: Text('${item.name} (AMD)', style: UIs.text13),
leading: Text(
'${item.utilization}%\n${item.temp} °C',
style: UIs.text12Grey,
textScaler: _textFactor,
textAlign: TextAlign.center,
),
subtitle: Text(
'${item.power} - FAN ${item.fanSpeed} RPM\n${item.clockSpeed} MHz\n${mem.used} / ${mem.total} ${mem.unit}',
style: UIs.text12Grey,
textScaler: _textFactor,
),
contentPadding: const EdgeInsets.only(left: 17, right: 17),
trailing: Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
IconButton(onPressed: () => _onTapAmdGpuItem(item), icon: const Icon(Icons.info_outline, size: 17)),
],
),
);
}
Widget _buildGpuProcessItem(NvidiaSmiMemProcess process) {
return ListTile(
title: Text(
process.name,
style: UIs.text12,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textScaler: _textFactor,
),
subtitle: Text(
'PID: ${process.pid} - ${process.memory} MiB',
style: UIs.text12Grey,
textScaler: _textFactor,
),
trailing: InkWell(
onTap: () => _onTapGpuProcessItem(process),
child: const Icon(Icons.info_outline, size: 17),
),
);
}
Widget _buildAmdGpuProcessItem(AmdSmiMemProcess process) {
return ListTile(
title: Text(
process.name,
style: UIs.text12,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textScaler: _textFactor,
),
subtitle: Text(
'PID: ${process.pid} - ${process.memory} MiB',
style: UIs.text12Grey,
textScaler: _textFactor,
),
trailing: InkWell(
onTap: () => _onTapAmdGpuProcessItem(process),
child: const Icon(Icons.info_outline, size: 17),
),
);
}
Widget? _buildDiskView(ServerState si) {
final ss = si.status;
final children = <Widget>[];
// Create widgets for each top-level disk
for (int idx = 0; idx < ss.disk.length; idx++) {
final disk = ss.disk[idx];
children.add(_buildDiskItemWithHierarchy(disk, ss, 0));
}
if (children.isEmpty) return null;
return ExpandTile(
title: Text(l10n.disk),
childrenPadding: const EdgeInsets.only(bottom: 7),
leading: Icon(ServerDetailCards.disk.icon, size: 17),
initiallyExpanded: _getInitExpand(children.length),
children: children,
).cardx;
}
Widget _buildDiskItemWithHierarchy(Disk disk, server_model.ServerStatus ss, int depth) {
// Create a list to hold this disk and its children
final items = <Widget>[];
// Add the current disk
items.add(_buildDiskItem(disk, ss, depth));
// Recursively add child disks with increased indentation
if (disk.children.isNotEmpty) {
for (final childDisk in disk.children) {
items.add(_buildDiskItemWithHierarchy(childDisk, ss, depth + 1));
}
}
return Column(children: items);
}
Widget _buildDiskItem(Disk disk, server_model.ServerStatus ss, int depth) {
final (read, write) = ss.diskIO.getSpeed(disk.path);
final text = () {
final use = '${l10n.used} ${disk.used.kb2Str} / ${disk.size.kb2Str}';
if (read == null || write == null) return use;
return '$use\n${l10n.read} $read | ${l10n.write} $write';
}();
return Padding(
padding: EdgeInsets.only(
left: 17.0 + (depth * 15.0), // Indent based on depth
right: 17.0,
top: 5.0,
bottom: 5.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
disk.mount.isEmpty ? disk.path : '${disk.path} (${disk.mount})',
style: UIs.text12,
textScaler: _textFactor,
),
Text(text, style: UIs.text12Grey, textScaler: _textFactor),
],
),
),
if (disk.size > BigInt.zero)
SizedBox(
height: 41,
width: 41,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: disk.usedPercent / 100,
strokeWidth: 5,
backgroundColor: UIs.halfAlpha,
color: UIs.primaryColor,
),
Text('${disk.usedPercent}%', style: UIs.text12Grey),
],
),
),
],
),
);
}
Widget? _buildDiskSmart(ServerState si) {
final smarts = si.status.diskSmart;
if (smarts.isEmpty) return null;
return CardX(
child: ExpandTile(
title: Text(l10n.diskHealth),
leading: Icon(ServerDetailCards.smart.icon, size: 17),
childrenPadding: const EdgeInsets.only(bottom: 7),
initiallyExpanded: _getInitExpand(smarts.length),
children: smarts.map(_buildDiskSmartItem).toList(),
),
);
}
Widget _buildDiskSmartItem(DiskSmart smart) {
final healthStatus = _getDiskHealthStatus(smart);
return ListTile(
dense: true,
leading: healthStatus.icon,
title: Text(smart.device, style: UIs.text13, textScaler: _textFactor),
trailing: Text(
healthStatus.text,
style: UIs.text13.copyWith(fontWeight: FontWeight.bold),
textScaler: _textFactor,
),
subtitle: _buildDiskSmartDetails(smart),
onTap: () => _onTapDiskSmartItem(smart),
);
}
({String text, Color color, Widget icon}) _getDiskHealthStatus(DiskSmart smart) {
if (smart.healthy == null) {
return (
text: libL10n.unknown,
color: Colors.orange,
icon: const Icon(Icons.help_outline, color: Colors.orange, size: 18),
);
} else if (smart.healthy!) {
return (
text: 'PASS',
color: Colors.green,
icon: const Icon(Icons.check_circle, color: Colors.green, size: 18),
);
} else {
return (text: 'FAIL', color: Colors.red, icon: const Icon(Icons.error, color: Colors.red, size: 18));
}
}
Widget? _buildDiskSmartDetails(DiskSmart smart) {
final details = <String>[];
if (smart.model != null) {
details.add(smart.model!);
}
if (smart.temperature != null) {
details.add('${smart.temperature!.toStringAsFixed(1)}°C');
}
if (smart.powerOnHours != null) {
final hours = smart.powerOnHours!;
details.add('$hours ${libL10n.hour}');
}
if (smart.ssdLifeLeft != null) {
details.add('Life left: ${smart.ssdLifeLeft}%');
}
if (details.isEmpty) return null;
return Text(
details.join(' | '),
style: UIs.text12Grey,
textScaler: _textFactor,
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
}
void _onTapDiskSmartItem(DiskSmart smart) {
final details = <String>[];
if (smart.model != null) details.add('Model: ${smart.model}');
if (smart.serial != null) details.add('Serial: ${smart.serial}');
if (smart.temperature != null) {
details.add('Temperature: ${smart.temperature!.toStringAsFixed(1)}°C');
}
if (smart.powerOnHours != null) {
details.add('Power On: ${smart.powerOnHours} ${libL10n.hour}');
}
if (smart.powerCycleCount != null) {
details.add('Power Cycle: ${smart.powerCycleCount}');
}
if (smart.ssdLifeLeft != null) {
details.add('Life Left: ${smart.ssdLifeLeft}%');
}
if (smart.lifetimeWritesGiB != null) {
details.add('Lifetime Write: ${smart.lifetimeWritesGiB} GiB');
}
if (smart.lifetimeReadsGiB != null) {
details.add('Lifetime Read: ${smart.lifetimeReadsGiB} GiB');
}
if (smart.averageEraseCount != null) {
details.add('Avg. Erase: ${smart.averageEraseCount}');
}
if (smart.unsafeShutdownCount != null) {
details.add('Unsafe Shutdown: ${smart.unsafeShutdownCount}');
}
final criticalAttrs = [
'Reallocated_Sector_Ct',
'Current_Pending_Sector',
'Offline_Uncorrectable',
'UDMA_CRC_Error_Count',
];
for (final attrName in criticalAttrs) {
final attr = smart.getAttribute(attrName);
if (attr != null && attr.rawValue != null) {
final value = attr.rawValue.toString();
details.add('${attrName.replaceAll('_', ' ')}: $value');
}
}
if (details.isEmpty) {
return;
}
final markdown = details.join('\n\n- ');
context.showRoundDialog(
title: smart.device,
child: MarkdownBody(
data: '- $markdown',
selectable: true,
styleSheet: MarkdownStyleSheet.fromTheme(
Theme.of(context),
).copyWith(p: UIs.text13Grey, h2: UIs.text15),
),
actions: Btnx.oks,
);
}
Widget? _buildNetView(ServerState si) {
final ss = si.status;
final ns = ss.netSpeed;
final children = <Widget>[];
final devices = ns.devices;
if (devices.isEmpty) return null;
devices.sort(_netSortType.value.getSortFunc(ns));
children.addAll(devices.map((e) => _buildNetSpeedItem(ns, e)));
return ExpandTile(
leading: Icon(ServerDetailCards.net.icon, size: 17),
title: Row(
children: [
Text(l10n.net),
UIs.width13,
_netSortType.listenVal(
(val) => InkWell(
onTap: () => _netSortType.value = val.next,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 377),
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
child: Row(
children: [
const Icon(Icons.sort, size: 17),
UIs.width7,
Text(val.name, style: UIs.text13Grey),
],
),
),
),
),
],
),
childrenPadding: const EdgeInsets.only(bottom: 11),
initiallyExpanded: _getInitExpand(children.length),
children: children,
).cardx;
}
Widget _buildNetSpeedItem(NetSpeed ns, String device) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 17),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device,
style: UIs.text12,
textScaler: _textFactor,
maxLines: 1,
overflow: TextOverflow.fade,
textAlign: TextAlign.left,
),
Text(
'${ns.sizeIn(device: device)} | ${ns.sizeOut(device: device)}',
style: UIs.text12Grey,
textScaler: _textFactor,
),
],
),
SizedBox(
width: 170,
child: Text(
'${ns.speedOut(device: device)}\n${ns.speedIn(device: device)}',
textAlign: TextAlign.end,
style: UIs.text13Grey,
),
),
],
),
);
}
Widget? _buildTemperature(ServerState si) {
final ss = si.status;
if (ss.temps.isEmpty) return null;
return CardX(
child: ExpandTile(
title: Text(l10n.temperature),
leading: const Icon(Icons.ac_unit, size: 20),
initiallyExpanded: _getInitExpand(ss.temps.devices.length),
childrenPadding: const EdgeInsets.only(bottom: 7),
children: ss.temps.devices.map((key) => _buildTemperatureItem(key, ss.temps.get(key))).toList(),
),
);
}
Widget _buildTemperatureItem(String key, double? val) {
return Padding(
padding: const EdgeInsets.only(left: 3, right: 17, top: 5, bottom: 5),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Btn.text(
text: key,
textStyle: UIs.text15,
onTap: () => _onTapTemperatureItem(key),
).paddingSymmetric(horizontal: 5),
Text('${val?.toStringAsFixed(1)}°C', style: UIs.text13Grey),
],
),
);
}
Widget? _buildBatteries(ServerState si) {
final ss = si.status;
if (ss.batteries.isEmpty) return null;
return CardX(
child: ExpandTile(
title: Text(l10n.battery),
leading: const Icon(Icons.battery_charging_full, size: 17),
childrenPadding: const EdgeInsets.only(bottom: 7),
initiallyExpanded: _getInitExpand(ss.batteries.length, 2),
children: ss.batteries.map(_buildBatteryItem).toList(),
),
);
}
Widget _buildBatteryItem(Battery battery) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 5),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${battery.name}', style: UIs.text15),
Text('${battery.status.name} - ${battery.cycle}', style: UIs.text13Grey),
],
),
Text('${battery.percent?.toStringAsFixed(0)}%', style: UIs.text13Grey),
],
),
);
}
Widget? _buildSensors(ServerState si) {
final ss = si.status;
if (ss.sensors.isEmpty) return UIs.placeholder;
return CardX(
child: ExpandTile(
title: Text(l10n.sensors),
leading: const Icon(Icons.thermostat, size: 17),
childrenPadding: const EdgeInsets.only(bottom: 7),
initiallyExpanded: _getInitExpand(ss.sensors.length, 2),
children: ss.sensors.map(_buildSensorItem).toList(),
),
);
}
Widget _buildSensorItem(SensorItem si) {
if (si.summary == null) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 7),
child: Text(si.device),
);
}
final itemW = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(si.device, style: UIs.text15),
UIs.width7,
Text('(${si.adapter.raw})', style: UIs.text13Grey),
],
),
Text(si.summary ?? '', style: UIs.text13Grey),
],
).expanded();
return InkWell(
onTap: () => _onTapSensorItem(si),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
itemW,
UIs.width7,
const Icon(Icons.keyboard_arrow_right, color: Colors.grey),
],
),
),
);
}
Widget? _buildPve(ServerState si) {
final addr = si.spi.custom?.pveAddr;
if (addr == null || addr.isEmpty) return null;
return CardX(
child: ListTile(
title: const Text('PVE'),
leading: const Icon(FontAwesome.server_solid, size: 17),
trailing: const Icon(Icons.chevron_right),
onTap: () => PvePage.route.go(context, PvePageArgs(spi: si.spi)),
),
);
}
Widget? _buildCustomCmd(ServerState si) {
final ss = si.status;
if (ss.customCmds.isEmpty) return null;
return CardX(
child: ExpandTile(
leading: const Icon(MingCute.command_line, size: 17),
title: Text(l10n.customCmd),
initiallyExpanded: _getInitExpand(ss.customCmds.length),
children: ss.customCmds.entries.map(_buildCustomCmdItem).toList(),
),
);
}
Widget _buildCustomCmdItem(MapEntry<String, String> cmd) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 7),
child: KvRow(
k: cmd.key,
v: cmd.value,
vBuilder: () {
if (!cmd.value.contains('\n')) return null;
return GestureDetector(
onTap: () => _onTapCustomItem(cmd),
child: const Icon(Icons.info_outline, size: 17, color: Colors.grey),
);
},
),
);
}
Widget _buildAnimatedText(Key key, String text, TextStyle style) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 277),
child: Text(key: key, text, style: style, textScaler: _textFactor),
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
);
}
}