diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 118c7681..56647a19 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -98,7 +98,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 - camera_avfoundation: 04b44aeb14070126c6529e5ab82cc7c9fca107cf + camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436 file_picker: fb04e739ae6239a76ce1f571863a196a922c87d4 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 @@ -112,7 +112,7 @@ SPEC CHECKSUMS: share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d - wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49 + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 watch_connectivity: 88e5bea25b473e66ef8d3f960954d154ed0356d6 PODFILE CHECKSUM: ec6ef69056f066e8b21a3391082f23b5ad2d37f8 diff --git a/lib/app.dart b/lib/app.dart index 6d27b3c5..e6c80cef 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,12 +2,13 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/generated/l10n/lib_l10n.dart'; import 'package:flutter/material.dart'; +import 'package:responsive_framework/responsive_framework.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/data/res/build_data.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/generated/l10n/l10n.dart'; -import 'package:server_box/view/page/home/home.dart'; +import 'package:server_box/view/page/home.dart'; import 'package:icons_plus/icons_plus.dart'; part 'intro.dart'; @@ -22,47 +23,67 @@ class MyApp extends StatelessWidget { listenable: RNodes.app, builder: (context, _) { if (!Stores.setting.useSystemPrimaryColor.fetch()) { - final colorSeed = Color(Stores.setting.colorSeed.fetch()); - UIs.colorSeed = colorSeed; - UIs.primaryColor = colorSeed; - return _buildApp( - context, - light: ThemeData( - useMaterial3: true, - colorSchemeSeed: UIs.colorSeed, - ), - dark: ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - colorSchemeSeed: UIs.colorSeed, - ), - ); + return _build(context); } - return DynamicColorBuilder( - builder: (light, dark) { - final lightTheme = ThemeData( - useMaterial3: true, - colorScheme: light, - ); - final darkTheme = ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - colorScheme: dark, - ); - if (context.isDark && dark != null) { - UIs.primaryColor = dark.primary; - } else if (!context.isDark && light != null) { - UIs.primaryColor = light.primary; - } - return _buildApp(context, light: lightTheme, dark: darkTheme); - }, - ); + + return _buildDynamicColor(context); }, ); } - Widget _buildApp(BuildContext ctx, - {required ThemeData light, required ThemeData dark}) { + Widget _build(BuildContext context) { + final colorSeed = Color(Stores.setting.colorSeed.fetch()); + UIs.colorSeed = colorSeed; + UIs.primaryColor = colorSeed; + + return _buildApp( + context, + light: ThemeData( + useMaterial3: true, + colorSchemeSeed: UIs.colorSeed, + appBarTheme: AppBarTheme( + scrolledUnderElevation: 0.0, + ), + ), + dark: ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorSchemeSeed: UIs.colorSeed, + appBarTheme: AppBarTheme( + scrolledUnderElevation: 0.0, + ), + ), + ); + } + + Widget _buildDynamicColor(BuildContext context) { + return DynamicColorBuilder( + builder: (light, dark) { + final lightTheme = ThemeData( + useMaterial3: true, + colorScheme: light, + ); + final darkTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: dark, + ); + if (context.isDark && dark != null) { + UIs.primaryColor = dark.primary; + } else if (!context.isDark && light != null) { + UIs.primaryColor = light.primary; + } + + return _buildApp(context, light: lightTheme, dark: darkTheme); + }, + ); + } + + Widget _buildApp( + BuildContext ctx, { + required ThemeData light, + required ThemeData dark, + }) { final tMode = Stores.setting.themeMode.fetch(); // Issue #57 final themeMode = switch (tMode) { @@ -74,6 +95,14 @@ class MyApp extends StatelessWidget { return MaterialApp( key: ValueKey(locale), + builder: (context, child) => ResponsiveBreakpoints.builder( + child: child ?? UIs.placeholder, + breakpoints: const [ + Breakpoint(start: 0, end: 450, name: MOBILE), + Breakpoint(start: 451, end: 800, name: TABLET), + Breakpoint(start: 801, end: 1920, name: DESKTOP), + ], + ), locale: locale, localizationsDelegates: const [ LibLocalizations.delegate, @@ -86,21 +115,25 @@ class MyApp extends StatelessWidget { themeMode: themeMode, theme: light.fixWindowsFont, darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont, - home: VirtualWindowFrame( - child: Builder( - builder: (context) { - context.setLibL10n(); - final appL10n = AppLocalizations.of(context); - if (appL10n != null) l10n = appL10n; + home: Builder( + builder: (context) { + context.setLibL10n(); + final appL10n = AppLocalizations.of(context); + if (appL10n != null) l10n = appL10n; - final intros = _IntroPage.builders; - if (intros.isNotEmpty) { - return _IntroPage(intros); - } + Widget child; + final intros = _IntroPage.builders; + if (intros.isNotEmpty) { + child = _IntroPage(intros); + } - return const HomePage(); - }, - ), + child = const HomePage(); + + return VirtualWindowFrame( + title: BuildData.name, + child: child, + ); + }, ), ); } diff --git a/lib/core/route.dart b/lib/core/route.dart index 15d30f45..f6b209ba 100644 --- a/lib/core/route.dart +++ b/lib/core/route.dart @@ -1,154 +1,9 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:server_box/data/model/server/private_key_info.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; -import 'package:server_box/data/res/store.dart'; -import 'package:server_box/view/page/container.dart'; -import 'package:server_box/view/page/home/home.dart'; -import 'package:server_box/view/page/ping.dart'; -import 'package:server_box/view/page/private_key/edit.dart'; -import 'package:server_box/view/page/server/detail/view.dart'; -import 'package:server_box/view/page/setting/platform/android.dart'; -import 'package:server_box/view/page/setting/platform/ios.dart'; -import 'package:server_box/view/page/snippet/result.dart'; -import 'package:server_box/view/page/ssh/page.dart'; -import 'package:server_box/view/page/setting/seq/virt_key.dart'; -import 'package:server_box/data/model/server/snippet.dart'; -import 'package:server_box/view/page/process.dart'; -import 'package:server_box/view/page/server/tab.dart'; -import 'package:server_box/view/page/setting/seq/srv_detail_seq.dart'; -import 'package:server_box/view/page/setting/seq/srv_seq.dart'; -import 'package:server_box/view/page/snippet/edit.dart'; -import 'package:server_box/view/page/storage/sftp.dart'; -import 'package:server_box/view/page/storage/sftp_mission.dart'; -class AppRoutes { - final Widget page; - final String title; +/// The args class for [AppRoute]. +final class SpiRequiredArgs { + /// The only required argument for this class. + final Spi spi; - AppRoutes(this.page, this.title); - - Future go(BuildContext context) { - return Navigator.push( - context, - Stores.setting.cupertinoRoute.fetch() - ? CupertinoPageRoute(builder: (context) => page) - : MaterialPageRoute(builder: (context) => page), - ); - } - - Future checkGo({ - required BuildContext context, - required bool Function() check, - }) { - if (check()) { - return go(context); - } - return Future.value(null); - } - - static AppRoutes serverDetail({Key? key, required Spi spi}) { - return AppRoutes(ServerDetailPage(key: key, spi: spi), 'server_detail'); - } - - static AppRoutes serverTab({Key? key}) { - return AppRoutes(ServerPage(key: key), 'server_tab'); - } - - static AppRoutes keyEdit({Key? key, PrivateKeyInfo? pki}) { - return AppRoutes( - PrivateKeyEditPage(pki: pki), - 'key_${pki == null ? 'add' : 'edit'}', - ); - } - - static AppRoutes snippetEdit({Key? key, Snippet? snippet}) { - return AppRoutes( - SnippetEditPage(snippet: snippet), - 'snippet_${snippet == null ? 'add' : 'edit'}', - ); - } - - static AppRoutes ssh({ - Key? key, - required Spi spi, - String? initCmd, - Snippet? initSnippet, - }) { - return AppRoutes( - SSHPage( - key: key, - spi: spi, - initCmd: initCmd, - initSnippet: initSnippet, - ), - 'ssh_term', - ); - } - - static AppRoutes sshVirtKeySetting({Key? key}) { - return AppRoutes(SSHVirtKeySettingPage(key: key), 'ssh_virt_key_setting'); - } - - static AppRoutes sftpMission({Key? key}) { - return AppRoutes(SftpMissionPage(key: key), 'sftp_mission'); - } - - static AppRoutes sftp( - {Key? key, required Spi spi, String? initPath, bool isSelect = false}) { - return AppRoutes( - SftpPage( - key: key, - spi: spi, - initPath: initPath, - isSelect: isSelect, - ), - 'sftp'); - } - - static AppRoutes docker({Key? key, required Spi spi}) { - return AppRoutes(ContainerPage(key: key, spi: spi), 'docker'); - } - - // static AppRoutes fullscreen({Key? key}) { - // return AppRoutes(FullScreenPage(key: key), 'fullscreen'); - // } - - static AppRoutes home({Key? key}) { - return AppRoutes(HomePage(key: key), 'home'); - } - - static AppRoutes ping({Key? key}) { - return AppRoutes(PingPage(key: key), 'ping'); - } - - static AppRoutes process({Key? key, required Spi spi}) { - return AppRoutes(ProcessPage(key: key, spi: spi), 'process'); - } - - static AppRoutes serverOrder({Key? key}) { - return AppRoutes(ServerOrderPage(key: key), 'server_order'); - } - - static AppRoutes serverDetailOrder({Key? key}) { - return AppRoutes(ServerDetailOrderPage(key: key), 'server_detail_order'); - } - - static AppRoutes iosSettings({Key? key}) { - return AppRoutes(IOSSettingsPage(key: key), 'ios_setting'); - } - - static AppRoutes androidSettings({Key? key}) { - return AppRoutes(AndroidSettingsPage(key: key), 'android_setting'); - } - - static AppRoutes snippetResult( - {Key? key, required List results}) { - return AppRoutes( - SnippetResultPage( - key: key, - results: results, - ), - 'snippet_result'); - } + const SpiRequiredArgs(this.spi); } diff --git a/lib/data/model/app/tab.dart b/lib/data/model/app/tab.dart index 9a0b6243..9ede20e9 100644 --- a/lib/data/model/app/tab.dart +++ b/lib/data/model/app/tab.dart @@ -1,7 +1,7 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:server_box/core/extension/context/locale.dart'; -import 'package:server_box/view/page/server/tab.dart'; +import 'package:server_box/view/page/server/tab/tab.dart'; // import 'package:server_box/view/page/setting/entry.dart'; import 'package:server_box/view/page/snippet/list.dart'; import 'package:server_box/view/page/ssh/tab.dart'; diff --git a/lib/data/model/server/server_private_info.dart b/lib/data/model/server/server_private_info.dart index d57a863b..b6dbbe50 100644 --- a/lib/data/model/server/server_private_info.dart +++ b/lib/data/model/server/server_private_info.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:equatable/equatable.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -20,7 +21,7 @@ part 'server_private_info.g.dart'; /// Nowaday, more fields are added to this class, and it's renamed to `Spi`. @JsonSerializable() @HiveType(typeId: 3) -class Spi { +class Spi with EquatableMixin { @HiveField(0) final String name; @HiveField(1) @@ -81,6 +82,10 @@ class Spi { @override String toString() => id; + + @override + List get props => + [name, ip, port, user, pwd, keyId, tags, alterUrl, autoConnect, jumpId, custom, wolCfg, envs]; } extension Spix on Spi { diff --git a/lib/data/model/server/snippet.dart b/lib/data/model/server/snippet.dart index cd81e23f..51bc0ad6 100644 --- a/lib/data/model/server/snippet.dart +++ b/lib/data/model/server/snippet.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:equatable/equatable.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -10,7 +11,7 @@ part 'snippet.g.dart'; @JsonSerializable() @HiveType(typeId: 2) -class Snippet { +class Snippet with EquatableMixin { @HiveField(0) final String name; @HiveField(1) @@ -32,11 +33,21 @@ class Snippet { this.autoRunOn, }); - factory Snippet.fromJson(Map json) => - _$SnippetFromJson(json); + factory Snippet.fromJson(Map json) => _$SnippetFromJson(json); Map toJson() => _$SnippetToJson(this); + @override + List get props => [ + name, + script, + tags, + note, + autoRunOn, + ]; +} + +extension SnippetX on Snippet { static final fmtFinder = RegExp(r'\$\{[^{}]+\}'); String fmtWithSpi(Spi spi) { diff --git a/lib/data/provider/app.dart b/lib/data/provider/app.dart index 94a4cec1..c94bbd0c 100644 --- a/lib/data/provider/app.dart +++ b/lib/data/provider/app.dart @@ -1,7 +1,27 @@ import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; -final class AppProvider { - const AppProvider._(); +part 'app.g.dart'; +part 'app.freezed.dart'; - static BuildContext? ctx; +@freezed +class AppState with _$AppState { + const factory AppState({ + @Default(false) bool desktopMode, + }) = _AppState; +} + +@Riverpod(keepAlive: true) +class AppProvider extends _$AppProvider { + static BuildContext? ctx; + + @override + AppState build() { + return const AppState(); + } + + void setDesktop(bool desktopMode) { + state = state.copyWith(desktopMode: desktopMode); + } } diff --git a/lib/data/provider/app.freezed.dart b/lib/data/provider/app.freezed.dart new file mode 100644 index 00000000..3e95769b --- /dev/null +++ b/lib/data/provider/app.freezed.dart @@ -0,0 +1,144 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'app.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$AppState { + bool get desktopMode => throw _privateConstructorUsedError; + + /// Create a copy of AppState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AppStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AppStateCopyWith<$Res> { + factory $AppStateCopyWith(AppState value, $Res Function(AppState) then) = + _$AppStateCopyWithImpl<$Res, AppState>; + @useResult + $Res call({bool desktopMode}); +} + +/// @nodoc +class _$AppStateCopyWithImpl<$Res, $Val extends AppState> + implements $AppStateCopyWith<$Res> { + _$AppStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AppState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? desktopMode = null, + }) { + return _then(_value.copyWith( + desktopMode: null == desktopMode + ? _value.desktopMode + : desktopMode // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$AppStateImplCopyWith<$Res> + implements $AppStateCopyWith<$Res> { + factory _$$AppStateImplCopyWith( + _$AppStateImpl value, $Res Function(_$AppStateImpl) then) = + __$$AppStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({bool desktopMode}); +} + +/// @nodoc +class __$$AppStateImplCopyWithImpl<$Res> + extends _$AppStateCopyWithImpl<$Res, _$AppStateImpl> + implements _$$AppStateImplCopyWith<$Res> { + __$$AppStateImplCopyWithImpl( + _$AppStateImpl _value, $Res Function(_$AppStateImpl) _then) + : super(_value, _then); + + /// Create a copy of AppState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? desktopMode = null, + }) { + return _then(_$AppStateImpl( + desktopMode: null == desktopMode + ? _value.desktopMode + : desktopMode // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc + +class _$AppStateImpl implements _AppState { + const _$AppStateImpl({this.desktopMode = false}); + + @override + @JsonKey() + final bool desktopMode; + + @override + String toString() { + return 'AppState(desktopMode: $desktopMode)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AppStateImpl && + (identical(other.desktopMode, desktopMode) || + other.desktopMode == desktopMode)); + } + + @override + int get hashCode => Object.hash(runtimeType, desktopMode); + + /// Create a copy of AppState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AppStateImplCopyWith<_$AppStateImpl> get copyWith => + __$$AppStateImplCopyWithImpl<_$AppStateImpl>(this, _$identity); +} + +abstract class _AppState implements AppState { + const factory _AppState({final bool desktopMode}) = _$AppStateImpl; + + @override + bool get desktopMode; + + /// Create a copy of AppState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AppStateImplCopyWith<_$AppStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/data/provider/app.g.dart b/lib/data/provider/app.g.dart new file mode 100644 index 00000000..93470c5e --- /dev/null +++ b/lib/data/provider/app.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$appProviderHash() => r'8378ec9d0a9c8d99cc05805047cd2d52ac4dbb56'; + +/// See also [AppProvider]. +@ProviderFor(AppProvider) +final appProviderProvider = NotifierProvider.internal( + AppProvider.new, + name: r'appProviderProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$appProviderHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$AppProvider = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/data/store/snippet.dart b/lib/data/store/snippet.dart index 9e095992..f17cb070 100644 --- a/lib/data/store/snippet.dart +++ b/lib/data/store/snippet.dart @@ -14,15 +14,14 @@ class SnippetStore extends HiveStore { } List fetch() { - final keys = box.keys; - final ss = []; - for (final key in keys) { + final ss = {}; + for (final key in keys()) { final s = box.get(key); if (s != null && s is Snippet) { ss.add(s); } } - return ss; + return ss.toList(); } void delete(Snippet s) { diff --git a/lib/view/page/backup.dart b/lib/view/page/backup.dart index bcbbd62d..c5a96211 100644 --- a/lib/view/page/backup.dart +++ b/lib/view/page/backup.dart @@ -20,6 +20,11 @@ class BackupPage extends StatefulWidget { @override State createState() => _BackupPageState(); + + static const route = AppRouteNoArg( + page: BackupPage.new, + path: '/backup', + ); } final class _BackupPageState extends State @@ -246,7 +251,7 @@ final class _BackupPageState extends State onTap: () async { final data = await context.showImportDialog( title: l10n.snippet, - modelDef: Snippet.example.toJson(), + modelDef: SnippetX.example.toJson(), ); if (data == null) return; final str = String.fromCharCodes(data); diff --git a/lib/view/page/container.dart b/lib/view/page/container.dart index 7faa8109..17b0452d 100644 --- a/lib/view/page/container.dart +++ b/lib/view/page/container.dart @@ -14,22 +14,28 @@ import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/model/container/ps.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/provider/container.dart'; +import 'package:server_box/view/page/ssh/page.dart'; import 'package:server_box/view/widget/two_line_text.dart'; class ContainerPage extends StatefulWidget { - final Spi spi; - const ContainerPage({required this.spi, super.key}); + final SpiRequiredArgs args; + const ContainerPage({required this.args, super.key}); @override State createState() => _ContainerPageState(); + + static const route = AppRouteArg( + page: ContainerPage.new, + path: '/container', + ); } class _ContainerPageState extends State { final _textController = TextEditingController(); late final _container = ContainerProvider( - client: widget.spi.server?.value.client, - userName: widget.spi.user, - hostId: widget.spi.id, + client: widget.args.spi.server?.value.client, + userName: widget.args.spi.user, + hostId: widget.args.spi.id, context: context, ); late Size _size; @@ -55,27 +61,23 @@ class _ContainerPageState extends State { @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (_) => _container, - builder: (_, __) => Consumer( - builder: (_, ___, __) { - return Scaffold( - appBar: CustomAppBar( - centerTitle: true, - title: TwoLineText(up: l10n.container, down: widget.spi.name), - actions: [ - IconButton( - onPressed: () => - context.showLoadingDialog(fn: () => _container.refresh()), - icon: const Icon(Icons.refresh), - ) - ], - ), - body: _buildMain(), - floatingActionButton: _container.error == null ? _buildFAB() : null, - ); - }, - ), + return Consumer( + builder: (_, ___, __) { + return Scaffold( + appBar: CustomAppBar( + centerTitle: true, + title: TwoLineText(up: l10n.container, down: widget.args.spi.name), + actions: [ + IconButton( + onPressed: () => context.showLoadingDialog(fn: () => _container.refresh()), + icon: const Icon(Icons.refresh), + ) + ], + ), + body: _buildMain(), + floatingActionButton: _container.error == null ? _buildFAB() : null, + ); + }, ); } @@ -234,8 +236,7 @@ class _ContainerPageState extends State { ), Row( children: [ - _buildPsItemStatsItem( - 'Mem', item.mem, Icons.settings_input_component), + _buildPsItemStatsItem('Mem', item.mem, Icons.settings_input_component), UIs.width13, _buildPsItemStatsItem('Disk', item.disk, Icons.storage), ], @@ -263,9 +264,7 @@ class _ContainerPageState extends State { Widget _buildMoreBtn(ContainerPs dItem) { return PopupMenu( - items: ContainerMenu.items(dItem.running) - .map((e) => PopMenu.build(e, e.icon, e.toStr)) - .toList(), + items: ContainerMenu.items(dItem.running).map((e) => PopMenu.build(e, e.icon, e.toStr)).toList(), onSelected: (item) => _onTapMoreBtn(item, dItem), ); } @@ -410,7 +409,7 @@ class _ContainerPageState extends State { } Future _showEditHostDialog() async { - final id = widget.spi.id; + final id = widget.args.spi.id; final host = Stores.container.fetch(id); final ctrl = TextEditingController(text: host); await context.showRoundDialog( @@ -428,7 +427,7 @@ class _ContainerPageState extends State { void _onSaveDockerHost(String val) { context.pop(); - Stores.container.put(widget.spi.id, val.trim()); + Stores.container.put(widget.args.spi.id, val.trim()); _container.refresh(); } @@ -537,22 +536,24 @@ class _ContainerPageState extends State { } break; case ContainerMenu.logs: - AppRoutes.ssh( - spi: widget.spi, + final args = SshPageArgs( + spi: widget.args.spi, initCmd: '${switch (_container.type) { ContainerType.podman => 'podman', ContainerType.docker => 'docker', }} logs -f --tail 100 ${dItem.id}', - ).go(context); + ); + SSHPage.route.go(context, args); break; case ContainerMenu.terminal: - AppRoutes.ssh( - spi: widget.spi, + final args = SshPageArgs( + spi: widget.args.spi, initCmd: '${switch (_container.type) { ContainerType.podman => 'podman', ContainerType.docker => 'docker', }} exec -it ${dItem.id} sh', - ).go(context); + ); + SSHPage.route.go(context, args); break; // case DockerMenuType.stats: // showRoundDialog( diff --git a/lib/view/page/home/home.dart b/lib/view/page/home.dart similarity index 54% rename from lib/view/page/home/home.dart rename to lib/view/page/home.dart index 195feac5..984e431a 100644 --- a/lib/view/page/home/home.dart +++ b/lib/view/page/home.dart @@ -1,6 +1,6 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; +import 'package:responsive_framework/responsive_framework.dart'; import 'package:server_box/core/chan.dart'; import 'package:server_box/data/model/app/tab.dart'; import 'package:server_box/data/provider/app.dart'; @@ -8,22 +8,23 @@ import 'package:server_box/data/provider/server.dart'; import 'package:server_box/data/res/build_data.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/url.dart'; +import 'package:server_box/view/page/setting/entry.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; -part 'appbar.dart'; - class HomePage extends StatefulWidget { const HomePage({super.key}); @override State createState() => _HomePageState(); + + static const route = AppRouteNoArg( + page: HomePage.new, + path: '/', + ); } class _HomePageState extends State - with - AutomaticKeepAliveClientMixin, - AfterLayoutMixin, - WidgetsBindingObserver { + with AutomaticKeepAliveClientMixin, AfterLayoutMixin, WidgetsBindingObserver { late final PageController _pageController; final _selectIndex = ValueNotifier(0); @@ -92,98 +93,81 @@ class _HomePageState extends State Widget build(BuildContext context) { super.build(context); AppProvider.ctx = context; - final sysPadding = MediaQuery.of(context).padding; + final isMobile = ResponsiveBreakpoints.of(context).isMobile; - return ColoredBox( - color: context.theme.colorScheme.surface, - child: AdaptiveLayout( - transitionDuration: const Duration(milliseconds: 250), - primaryNavigation: SlotLayout( - config: { - Breakpoints.medium: SlotLayout.from( - key: const Key('primaryNavigation'), - builder: (context) => _buildRailBar(), + return Scaffold( + appBar: _AppBar(MediaQuery.paddingOf(context).top), + body: Row( + children: [ + if (!isMobile) _buildRailBar(), + Expanded( + child: PageView.builder( + controller: _pageController, + itemCount: AppTab.values.length, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (_, index) => AppTab.values[index].page, + onPageChanged: (value) { + FocusScope.of(context).unfocus(); + if (!_switchingPage) { + _selectIndex.value = value; + } + }, ), - Breakpoints.mediumLarge: SlotLayout.from( - key: const Key('MediumLarge primaryNavigation'), - builder: (context) => _buildRailBar(extended: true), - ), - Breakpoints.large: SlotLayout.from( - key: const Key('Large primaryNavigation'), - builder: (context) => _buildRailBar(extended: true), - ), - Breakpoints.extraLarge: SlotLayout.from( - key: const Key('ExtraLarge primaryNavigation'), - builder: (context) => _buildRailBar(extended: true), - ), - }, - ), - body: SlotLayout( - config: { - Breakpoint.standard(): SlotLayout.from( - key: const Key('body'), - builder: (context) => Scaffold( - appBar: _AppBar(sysPadding.top), - body: PageView.builder( - controller: _pageController, - itemCount: AppTab.values.length, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (_, index) => AppTab.values[index].page, - onPageChanged: (value) { - FocusScope.of(context).unfocus(); - if (!_switchingPage) { - _selectIndex.value = value; - } - }, - ), - ), - ), - }, - ), - bottomNavigation: SlotLayout( - config: { - Breakpoints.small: SlotLayout.from( - key: const Key('bottomNavigation'), - builder: (context) => _buildBottomBar(), - ), - }, - ), + ), + ], ), + bottomNavigationBar: isMobile ? _buildBottomBar() : null, ); } Widget _buildBottomBar() { - return Stores.setting.fullScreen.fetch() - ? UIs.placeholder - : ListenableBuilder( - listenable: _selectIndex, - builder: (context, child) => NavigationBar( - selectedIndex: _selectIndex.value, - height: kBottomNavigationBarHeight * 1.1, - animationDuration: const Duration(milliseconds: 250), - onDestinationSelected: _onDestinationSelected, - labelBehavior: - NavigationDestinationLabelBehavior.onlyShowSelected, - destinations: AppTab.navDestinations, - ), - ); + if (Stores.setting.fullScreen.fetch()) return UIs.placeholder; + return ListenableBuilder( + listenable: _selectIndex, + builder: (context, child) => NavigationBar( + selectedIndex: _selectIndex.value, + height: kBottomNavigationBarHeight * 1.1, + animationDuration: const Duration(milliseconds: 250), + onDestinationSelected: _onDestinationSelected, + labelBehavior: NavigationDestinationLabelBehavior.onlyShowSelected, + destinations: AppTab.navDestinations, + ), + ); } Widget _buildRailBar({bool extended = false}) { - return Stores.setting.fullScreen.fetch() - ? UIs.placeholder - : ListenableBuilder( - listenable: _selectIndex, - builder: (context, child) => - AdaptiveScaffold.standardNavigationRail( - extended: extended, - padding: EdgeInsets.only(top: CustomAppBar.sysStatusBarHeight), - selectedIndex: _selectIndex.value, - destinations: AppTab.navRailDestinations, - onDestinationSelected: _onDestinationSelected, - labelType: extended ? null : NavigationRailLabelType.selected, - ), - ); + final fullscreen = Stores.setting.fullScreen.fetch(); + if (fullscreen) return UIs.placeholder; + + return Stack( + children: [ + _selectIndex.listenVal( + (idx) => NavigationRail( + extended: extended, + minExtendedWidth: 150, + leading: extended ? const SizedBox(height: 20) : null, + trailing: extended ? const SizedBox(height: 20) : null, + labelType: extended ? NavigationRailLabelType.none : NavigationRailLabelType.all, + selectedIndex: idx, + destinations: AppTab.navRailDestinations, + onDestinationSelected: _onDestinationSelected, + ), + ), + // Settings Btn + Positioned( + bottom: 10, + left: 0, + right: 0, + child: IconButton( + icon: const Icon(Icons.settings), + tooltip: libL10n.setting, + onPressed: () { + SettingsPage.route.go(context); + }, + ), + ), + ], + ); } @override @@ -231,7 +215,7 @@ class _HomePageState extends State void _goAuth() { if (Stores.setting.useBioAuth.fetch()) { - if (BioAuthPage.route.isAlreadyIn) return; + if (BioAuthPage.route.alreadyIn) return; BioAuthPage.route.go( context, args: BioAuthPageArgs(onAuthSuccess: () => _shouldAuth = false), @@ -253,3 +237,21 @@ class _HomePageState extends State }); } } + +final class _AppBar extends StatelessWidget implements PreferredSizeWidget { + final double paddingTop; + + const _AppBar(this.paddingTop); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: preferredSize.height, + ); + } + + @override + Size get preferredSize { + return Size.fromHeight(paddingTop); + } +} diff --git a/lib/view/page/home/appbar.dart b/lib/view/page/home/appbar.dart deleted file mode 100644 index d57084b1..00000000 --- a/lib/view/page/home/appbar.dart +++ /dev/null @@ -1,23 +0,0 @@ -part of 'home.dart'; - -final class _AppBar extends StatelessWidget implements PreferredSizeWidget { - final double paddingTop; - - const _AppBar(this.paddingTop); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: preferredSize.height, - ); - } - - @override - Size get preferredSize { - final height = switch (Pfs.type) { - Pfs.macos => paddingTop + CustomAppBar.sysStatusBarHeight, - _ => paddingTop, - }; - return Size.fromHeight(height); - } -} diff --git a/lib/view/page/iperf.dart b/lib/view/page/iperf.dart index d4b7e100..b1e6bc1f 100644 --- a/lib/view/page/iperf.dart +++ b/lib/view/page/iperf.dart @@ -2,23 +2,18 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/route.dart'; -import 'package:server_box/data/model/server/server_private_info.dart'; +import 'package:server_box/view/page/ssh/page.dart'; -final class IPerfPageArgs { - final Spi spi; - - const IPerfPageArgs({required this.spi}); -} class IPerfPage extends StatefulWidget { - final IPerfPageArgs args; + final SpiRequiredArgs args; const IPerfPage({super.key, required this.args}); @override State createState() => _IPerfPageState(); - static const route = AppRouteArg( + static const route = AppRouteArg( page: IPerfPage.new, path: '/iperf', ); @@ -55,10 +50,11 @@ class _IPerfPageState extends State { context.showSnackBar(libL10n.empty); return; } - AppRoutes.ssh( + final args = SshPageArgs( spi: widget.args.spi, initCmd: 'iperf -c ${_hostCtrl.text} -p ${_portCtrl.text}', - ).go(context); + ); + SSHPage.route.go(context, args); }, ); } diff --git a/lib/view/page/ping.dart b/lib/view/page/ping.dart index a472878c..81a27837 100644 --- a/lib/view/page/ping.dart +++ b/lib/view/page/ping.dart @@ -15,6 +15,11 @@ class PingPage extends StatefulWidget { @override State createState() => _PingPageState(); + + static const route = AppRouteNoArg( + page: PingPage.new, + path: '/ping', + ); } class _PingPageState extends State diff --git a/lib/view/page/private_key/edit.dart b/lib/view/page/private_key/edit.dart index 321a25aa..fe470934 100644 --- a/lib/view/page/private_key/edit.dart +++ b/lib/view/page/private_key/edit.dart @@ -13,13 +13,22 @@ import 'package:server_box/data/model/server/private_key_info.dart'; const _format = 'text/plain'; -class PrivateKeyEditPage extends StatefulWidget { - const PrivateKeyEditPage({super.key, this.pki}); - +final class PrivateKeyEditPageArgs { final PrivateKeyInfo? pki; + const PrivateKeyEditPageArgs({this.pki}); +} + +class PrivateKeyEditPage extends StatefulWidget { + final PrivateKeyEditPageArgs? args; + const PrivateKeyEditPage({super.key, this.args}); @override State createState() => _PrivateKeyEditPageState(); + + static const route = AppRoute( + page: PrivateKeyEditPage.new, + path: '/private_key/edit', + ); } class _PrivateKeyEditPageState extends State { @@ -34,6 +43,8 @@ class _PrivateKeyEditPageState extends State { final _loading = ValueNotifier(null); + PrivateKeyInfo? get pki => widget.args?.pki; + @override void dispose() { super.dispose(); @@ -49,9 +60,10 @@ class _PrivateKeyEditPageState extends State { @override void initState() { super.initState(); - if (widget.pki != null) { - _nameController.text = widget.pki!.id; - _keyController.text = widget.pki!.key; + final pki = this.pki; + if (pki != null) { + _nameController.text = pki.id; + _keyController.text = pki.key; } else { Clipboard.getData(_format).then((value) { if (value == null) return; @@ -79,31 +91,34 @@ class _PrivateKeyEditPageState extends State { } CustomAppBar _buildAppBar() { - final actions = [ - IconButton( - tooltip: libL10n.delete, - onPressed: () { - context.showRoundDialog( - title: libL10n.attention, - child: Text(libL10n.askContinue( - '${libL10n.delete} ${l10n.privateKey}(${widget.pki!.id})', - )), - actions: Btn.ok( - onTap: () { - PrivateKeyProvider.delete(widget.pki!); - context.pop(); - context.pop(); + final pki = this.pki; + final actions = pki != null + ? [ + IconButton( + tooltip: libL10n.delete, + onPressed: () { + context.showRoundDialog( + title: libL10n.attention, + child: Text(libL10n.askContinue( + '${libL10n.delete} ${l10n.privateKey}(${pki.id})', + )), + actions: Btn.ok( + onTap: () { + PrivateKeyProvider.delete(pki); + context.pop(); + context.pop(); + }, + red: true, + ).toList, + ); }, - red: true, - ).toList, - ); - }, - icon: const Icon(Icons.delete), - ) - ]; + icon: const Icon(Icons.delete), + ) + ] + : null; return CustomAppBar( title: Text(libL10n.edit), - actions: widget.pki == null ? null : actions, + actions: actions, ); } @@ -120,8 +135,7 @@ class _PrivateKeyEditPageState extends State { } Widget _buildBody() { - return ListView( - padding: const EdgeInsets.all(13), + return AutoMultiList( children: [ Input( autoFocus: true, @@ -204,7 +218,7 @@ class _PrivateKeyEditPageState extends State { try { final decrypted = await Computer.shared.start(decyptPem, [key, pwd]); final pki = PrivateKeyInfo(id: name, key: decrypted); - final originPki = widget.pki; + final originPki = this.pki; if (originPki != null) { PrivateKeyProvider.update(originPki, pki); } else { diff --git a/lib/view/page/private_key/list.dart b/lib/view/page/private_key/list.dart index 61c6d9f1..98164a84 100644 --- a/lib/view/page/private_key/list.dart +++ b/lib/view/page/private_key/list.dart @@ -6,26 +6,30 @@ import 'package:flutter/material.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/data/res/store.dart'; -import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/server/private_key_info.dart'; import 'package:server_box/data/provider/private_key.dart'; +import 'package:server_box/view/page/private_key/edit.dart'; class PrivateKeysListPage extends StatefulWidget { const PrivateKeysListPage({super.key}); @override State createState() => _PrivateKeyListState(); + + static const route = AppRouteNoArg( + page: PrivateKeysListPage.new, + path: '/private_key', + ); } -class _PrivateKeyListState extends State - with AfterLayoutMixin { +class _PrivateKeyListState extends State with AfterLayoutMixin { @override Widget build(BuildContext context) { return Scaffold( body: _buildBody(), floatingActionButton: FloatingActionButton( child: const Icon(Icons.add), - onPressed: () => AppRoutes.keyEdit().go(context), + onPressed: () => PrivateKeyEditPage.route.go(context), ), ); } @@ -36,33 +40,33 @@ class _PrivateKeyListState extends State if (pkis.isEmpty) { return Center(child: Text(libL10n.empty)); } - return ListView.builder( - padding: const EdgeInsets.all(13), - itemCount: pkis.length, - itemBuilder: (context, idx) { - final item = pkis[idx]; - return CardX( - child: ListTile( - leading: Text( - '#$idx', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - ), - ), - title: Text(item.id), - subtitle: Text(item.type ?? l10n.unknown, style: UIs.textGrey), - onTap: () => AppRoutes.keyEdit(pki: item).go(context), - trailing: const Icon(Icons.edit), - ), - ); - }, - ); + + final children = pkis.map(_buildKeyItem).toList(); + return AutoMultiList(children: children); }, ); } - void autoAddSystemPriavteKey() { + Widget _buildKeyItem(PrivateKeyInfo item) { + return ListTile( + title: Text(item.id), + subtitle: Text(item.type ?? l10n.unknown, style: UIs.textGrey), + onTap: () => PrivateKeyEditPage.route.go( + context, + args: PrivateKeyEditPageArgs(pki: item), + ), + trailing: const Icon(Icons.edit), + ).cardx; + } + + @override + FutureOr afterFirstLayout(BuildContext context) { + _autoAddSystemPriavteKey(); + } +} + +extension on _PrivateKeyListState { + void _autoAddSystemPriavteKey() async { // Only trigger on desktop platform and no private key saved if (isDesktop && Stores.snippet.box.keys.isEmpty) { final home = Pfs.homeDir; @@ -71,21 +75,19 @@ class _PrivateKeyListState extends State if (!idRsaFile.existsSync()) return; final sysPk = PrivateKeyInfo( id: 'system', - key: idRsaFile.readAsStringSync(), + key: await idRsaFile.readAsString(), ); context.showRoundDialog( title: libL10n.attention, child: Text(l10n.addSystemPrivateKeyTip), actions: Btn.ok(onTap: () { context.pop(); - AppRoutes.keyEdit(pki: sysPk).go(context); + PrivateKeyEditPage.route.go( + context, + args: PrivateKeyEditPageArgs(pki: sysPk), + ); }).toList, ); } } - - @override - FutureOr afterFirstLayout(BuildContext context) { - autoAddSystemPriavteKey(); - } } diff --git a/lib/view/page/process.dart b/lib/view/page/process.dart index 16a9e9ea..1a3195d4 100644 --- a/lib/view/page/process.dart +++ b/lib/view/page/process.dart @@ -4,6 +4,7 @@ import 'package:dartssh2/dartssh2.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:server_box/core/extension/context/locale.dart'; +import 'package:server_box/core/route.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/model/app/shell_func.dart'; @@ -12,11 +13,17 @@ import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/view/widget/two_line_text.dart'; class ProcessPage extends StatefulWidget { - final Spi spi; - const ProcessPage({super.key, required this.spi}); + final SpiRequiredArgs args; + + const ProcessPage({super.key, required this.args}); @override State createState() => _ProcessPageState(); + + static const route = AppRouteArg( + page: ProcessPage.new, + path: '/process', + ); } class _ProcessPageState extends State { @@ -43,7 +50,7 @@ class _ProcessPageState extends State { @override void initState() { super.initState(); - _client = widget.spi.server?.value.client; + _client = widget.args.spi.server?.value.client; final duration = Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch()); _timer = Timer.periodic(duration, (_) => _refresh()); @@ -58,7 +65,7 @@ class _ProcessPageState extends State { Future _refresh() async { if (mounted) { final result = - await _client?.run(ShellFunc.process.exec(widget.spi.id)).string; + await _client?.run(ShellFunc.process.exec(widget.args.spi.id)).string; if (result == null || result.isEmpty) { context.showSnackBar(libL10n.empty); return; @@ -125,7 +132,7 @@ class _ProcessPageState extends State { return Scaffold( appBar: CustomAppBar( centerTitle: true, - title: TwoLineText(up: widget.spi.name, down: l10n.process), + title: TwoLineText(up: widget.args.spi.name, down: l10n.process), actions: actions, ), body: child, diff --git a/lib/view/page/pve.dart b/lib/view/page/pve.dart index 44505a8b..d17fd721 100644 --- a/lib/view/page/pve.dart +++ b/lib/view/page/pve.dart @@ -24,10 +24,13 @@ final class PvePage extends StatefulWidget { required this.args, }); - static const route = AppRouteArg(page: PvePage.new, path: '/pve'); - @override State createState() => _PvePageState(); + + static const route = AppRouteArg( + page: PvePage.new, + path: '/pve', + ); } const _kHorziPadding = 11.0; @@ -454,9 +457,7 @@ extension on _PvePageState { } void _initRefreshTimer() { - _timer = Timer.periodic( - Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch()), - (_) { + _timer = Timer.periodic(Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch()), (_) { if (mounted) { _pve.list(); } diff --git a/lib/view/page/server/detail/misc.dart b/lib/view/page/server/detail/misc.dart index a0c40026..d9bca66d 100644 --- a/lib/view/page/server/detail/misc.dart +++ b/lib/view/page/server/detail/misc.dart @@ -1,5 +1,83 @@ part of 'view.dart'; +extension on _ServerDetailPageState { + void _onTapGpuItem(NvidiaSmiItem item) { + final processes = item.memory.processes; + final displayCount = processes.length > 5 ? 5 : processes.length; + final height = displayCount * 47.0; + context.showRoundDialog( + title: item.name, + child: SizedBox( + width: double.maxFinite, + height: height, + child: ListView.builder( + itemCount: processes.length, + itemBuilder: (_, idx) => _buildGpuProcessItem(processes[idx]), + ), + ), + actions: Btnx.oks, + ); + } + + void _nTapGpuProcessItem(NvidiaSmiMemProcess process) { + context.showRoundDialog( + title: '${process.pid}', + titleMaxLines: 1, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UIs.height13, + Text('Memory: ${process.memory} MiB'), + UIs.height13, + Text('Process: ${process.name}') + ], + ), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text(libL10n.close), + ) + ], + ); + } + + void _onTapCustomItem(MapEntry cmd) { + context.showRoundDialog( + title: cmd.key, + child: SingleChildScrollView( + child: Text(cmd.value, style: UIs.text13Grey), + ), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text(libL10n.close), + ), + ], + ); + } + + void _onTapSensorItem(SensorItem si) { + context.showRoundDialog( + title: si.device, + child: SingleChildScrollView( + child: SimpleMarkdown( + data: si.toMarkdown, + styleSheet: MarkdownStyleSheet( + tableBorder: TableBorder.all(color: Colors.grey), + tableHead: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ); + } + + void _onTapTemperatureItem(String key) { + Pfs.copy(key); + context.showSnackBar('${libL10n.copy} ${libL10n.success}'); + } +} + enum _NetSortType { device, trans, @@ -26,13 +104,9 @@ enum _NetSortType { case _NetSortType.device: return (b, a) => a.compareTo(b); case _NetSortType.recv: - return (b, a) => ns - .speedInBytes(ns.deviceIdx(a)) - .compareTo(ns.speedInBytes(ns.deviceIdx(b))); + return (b, a) => ns.speedInBytes(ns.deviceIdx(a)).compareTo(ns.speedInBytes(ns.deviceIdx(b))); case _NetSortType.trans: - return (b, a) => ns - .speedOutBytes(ns.deviceIdx(a)) - .compareTo(ns.speedOutBytes(ns.deviceIdx(b))); + return (b, a) => ns.speedOutBytes(ns.deviceIdx(a)).compareTo(ns.speedOutBytes(ns.deviceIdx(b))); } } } diff --git a/lib/view/page/server/detail/view.dart b/lib/view/page/server/detail/view.dart index 358fbbd3..90250157 100644 --- a/lib/view/page/server/detail/view.dart +++ b/lib/view/page/server/detail/view.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:icons_plus/icons_plus.dart'; import 'package:server_box/core/extension/context/locale.dart'; +import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/app/server_detail_card.dart'; import 'package:server_box/data/model/app/shell_func.dart'; import 'package:server_box/data/model/server/battery.dart'; @@ -25,16 +26,19 @@ import 'package:server_box/data/model/server/server.dart'; part 'misc.dart'; class ServerDetailPage extends StatefulWidget { - const ServerDetailPage({super.key, required this.spi}); - - final Spi spi; + final SpiRequiredArgs args; + const ServerDetailPage({super.key, required this.args}); @override State createState() => _ServerDetailPageState(); + + static const route = AppRouteArg( + page: ServerDetailPage.new, + path: '/servers/detail', + ); } -class _ServerDetailPageState extends State - with SingleTickerProviderStateMixin { +class _ServerDetailPageState extends State with SingleTickerProviderStateMixin { late final _cardBuildMap = Map.fromIterables( ServerDetailCards.names, [ @@ -49,7 +53,7 @@ class _ServerDetailPageState extends State _buildTemperature, _buildBatteries, _buildPve, - _buildCustom, + _buildCustomCmd, ], ); @@ -83,7 +87,7 @@ class _ServerDetailPageState extends State @override Widget build(BuildContext context) { - final s = widget.spi.server; + final s = widget.args.spi.server; if (s == null) { return Scaffold( appBar: CustomAppBar(), @@ -106,14 +110,10 @@ class _ServerDetailPageState extends State children.add(buildFunc(si)); } } + return Scaffold( appBar: _buildAppBar(si), - body: ListView( - padding: EdgeInsets.only( - left: 13, - right: 13, - bottom: _media.padding.bottom + 77, - ), + body: AutoMultiList( children: children, ), ); @@ -121,18 +121,11 @@ class _ServerDetailPageState extends State CustomAppBar _buildAppBar(Server si) { return CustomAppBar( - title: Hero( - tag: 'home_card_title_${si.spi.id}', - transitionOnUserGestures: true, - child: Material( - color: Colors.transparent, - child: Text( - si.spi.name, - style: TextStyle( - fontSize: 20, - color: context.isDark ? Colors.white : Colors.black, - ), - ), + title: Text( + si.spi.name, + style: TextStyle( + fontSize: 20, + color: context.isDark ? Colors.white : Colors.black, ), ), actions: [ @@ -144,7 +137,10 @@ class _ServerDetailPageState extends State IconButton( icon: const Icon(Icons.edit), onPressed: () async { - final delete = await ServerEditPage.route.go(context, args: si.spi); + final delete = await ServerEditPage.route.go( + context, + args: SpiRequiredArgs(si.spi), + ); if (delete == true) { context.pop(); } @@ -155,16 +151,14 @@ class _ServerDetailPageState extends State } Widget _buildLogo(Server si) { - var logoUrl = si.spi.custom?.logoUrl ?? - _settings.serverLogoUrl.fetch().selfNotEmptyOrNull; + var logoUrl = si.spi.custom?.logoUrl ?? _settings.serverLogoUrl.fetch().selfNotEmptyOrNull; if (logoUrl == null) return UIs.placeholder; final dist = si.status.more[StatusCmdType.sys]?.dist; if (dist != null) { logoUrl = logoUrl.replaceFirst('{DIST}', dist.name); } - logoUrl = - logoUrl.replaceFirst('{BRIGHT}', context.isDark ? 'dark' : 'light'); + logoUrl = logoUrl.replaceFirst('{BRIGHT}', context.isDark ? 'dark' : 'light'); return Padding( padding: const EdgeInsets.symmetric(vertical: 13), @@ -194,8 +188,16 @@ class _ServerDetailPageState extends State child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(e.key.i18n, style: UIs.text13), - Text(e.value, style: UIs.text13Grey) + Text( + e.key.i18n, + style: UIs.text13, + overflow: TextOverflow.ellipsis, + ), + Text( + e.value, + style: UIs.text13Grey, + overflow: TextOverflow.ellipsis, + ), ], ), ), @@ -267,15 +269,15 @@ class _ServerDetailPageState extends State } Widget _buildCpuModelItem(MapEntry e) { - final name = e.key - .replaceFirst('Intel(R)', '') - .replaceFirst('AMD', '') - .replaceFirst('with Radeon Graphics', ''); + final name = + e.key.replaceFirst('Intel(R)', '').replaceFirst('AMD', '').replaceFirst('with Radeon Graphics', ''); final child = Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - SizedBox( - width: _media.size.width * .7, + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: _media.size.width * .7, + ), child: Text( name, style: UIs.text13, @@ -283,7 +285,7 @@ class _ServerDetailPageState extends State maxLines: 1, ), ), - Text('x ${e.value}', style: UIs.text13Grey), + Text('x ${e.value}', style: UIs.text13Grey, overflow: TextOverflow.clip), ], ); return child.paddingSymmetric(horizontal: 17); @@ -312,41 +314,47 @@ class _ServerDetailPageState extends State List _buildCPUProgress(Cpus cs) { const kMaxColumn = 2; const kRowThreshold = 4; - const kCoresCount = kMaxColumn * kRowThreshold; + const kCoresCountThreshold = kMaxColumn * kRowThreshold; final children = []; + final displayCpuIndexSetting = Stores.setting.displayCpuIndex.fetch(); - if (cs.coresCount > kCoresCount) { - final rows = cs.coresCount ~/ kMaxColumn; - for (var i = 0; i < rows; i++) { + if (cs.coresCount > kCoresCountThreshold) { + final numCoresToDisplay = cs.coresCount - 1; + final numRows = (numCoresToDisplay + kMaxColumn - 1) ~/ kMaxColumn; + + for (var i = 0; i < numRows; i++) { final rowChildren = []; for (var j = 0; j < kMaxColumn; j++) { - final idx = i * kMaxColumn + j + 1; - if (idx >= cs.coresCount) break; - if (Stores.setting.displayCpuIndex.fetch()) { - rowChildren.add(Text('$idx', style: UIs.text13Grey)); + 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: idx)), + child: _buildProgress(cs.usedPercent(coreIdx: coreNumberOneBased)), ), ), ); } - rowChildren.joinWith(UIs.width7); - children.add( - Padding( - padding: const EdgeInsets.symmetric(horizontal: 17), - child: Row( - children: rowChildren, + if (rowChildren.isNotEmpty) { + children.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 17), + child: Row( + children: rowChildren.joinWith(UIs.width7).toList(), + ), ), - ), - ); + ); + } } } else { - for (var i = 0; i < cs.coresCount; i++) { - if (i == 0) continue; + for (var i = 1; i < cs.coresCount; i++) { children.add( Padding( padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 17), @@ -377,6 +385,17 @@ class _ServerDetailPageState extends State 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 CardX( child: Padding( padding: UIs.roundRectCardPadding, @@ -387,20 +406,7 @@ class _ServerDetailPageState extends State Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - _buildAnimatedText( - ValueKey(usedStr), - '$usedStr%', - UIs.text27, - ), - UIs.width7, - Text( - 'of ${(ss.mem.total * 1024).bytes2Str}', - style: UIs.text13Grey, - ) - ], - ), + percentW, Row( children: [ _buildDetailPercent(free, 'free'), @@ -423,6 +429,18 @@ class _ServerDetailPageState extends State if (ss.swap.total == 0) return UIs.placeholder; 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 CardX( child: Padding( padding: UIs.roundRectCardPadding, @@ -433,16 +451,7 @@ class _ServerDetailPageState extends State Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - Text('${used.toStringAsFixed(0)}%', style: UIs.text27), - UIs.width7, - Text( - 'of ${(ss.swap.total * 1024).bytes2Str} ', - style: UIs.text13Grey, - ) - ], - ), + percentW, _buildDetailPercent(cached, 'cached'), ], ), @@ -470,7 +479,6 @@ class _ServerDetailPageState extends State Widget _buildGpuItem(NvidiaSmiItem item) { final mem = item.memory; - final processes = mem.processes; return ListTile( title: Text(item.name, style: UIs.text13), leading: Text( @@ -490,32 +498,7 @@ class _ServerDetailPageState extends State mainAxisSize: MainAxisSize.min, children: [ IconButton( - onPressed: () { - final height = () { - if (processes.length > 5) { - return 5 * 47.0; - } - return processes.length * 47.0; - }(); - context.showRoundDialog( - title: item.name, - child: SizedBox( - width: double.maxFinite, - height: height, - child: ListView.builder( - itemCount: processes.length, - itemBuilder: (_, idx) => - _buildGpuProcessItem(processes[idx]), - ), - ), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text(libL10n.close), - ) - ], - ); - }, + onPressed: () => _onTapGpuItem(item), icon: const Icon(Icons.info_outline, size: 17), ), ], @@ -538,28 +521,7 @@ class _ServerDetailPageState extends State textScaler: _textFactor, ), trailing: InkWell( - onTap: () { - context.showRoundDialog( - title: '${process.pid}', - titleMaxLines: 1, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - UIs.height13, - Text('Memory: ${process.memory} MiB'), - UIs.height13, - Text('Process: ${process.name}') - ], - ), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text(libL10n.close), - ) - ], - ); - }, + onTap: () => _nTapGpuProcessItem(process), child: const Icon(Icons.info_outline, size: 17), ), ); @@ -567,8 +529,7 @@ class _ServerDetailPageState extends State Widget _buildDiskView(Server si) { final ss = si.status; - final children = List.generate( - ss.disk.length, (idx) => _buildDiskItem(ss.disk[idx], ss)); + final children = List.generate(ss.disk.length, (idx) => _buildDiskItem(ss.disk[idx], ss)); return CardX( child: ExpandTile( title: Text(l10n.disk), @@ -587,6 +548,7 @@ class _ServerDetailPageState extends State if (read == null || write == null) return use; return '$use\n${l10n.read} $read | ${l10n.write} $write'; }(); + return Padding( padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 5), child: Row( @@ -638,43 +600,40 @@ class _ServerDetailPageState extends State devices.sort(_netSortType.value.getSortFunc(ns)); children.addAll(devices.map((e) => _buildNetSpeedItem(ns, e))); - return CardX( - child: ExpandTile( - leading: Icon(ServerDetailCards.net.icon, size: 17), - title: Row( - children: [ - Text(l10n.net), - UIs.width13, - ValBuilder( - listenable: _netSortType, - builder: (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, - ), - ], - ), + 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, + ), + ], ), - ); + childrenPadding: const EdgeInsets.only(bottom: 11), + initiallyExpanded: _getInitExpand(children.length), + children: children, + ).cardx; } Widget _buildNetSpeedItem(NetSpeed ns, String device) { @@ -725,9 +684,7 @@ class _ServerDetailPageState extends State 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(), + children: ss.temps.devices.map((key) => _buildTemperatureItem(key, ss.temps.get(key))).toList(), ), ); } @@ -738,12 +695,11 @@ class _ServerDetailPageState extends State child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(key, style: UIs.text15).paddingSymmetric(horizontal: 5).tap( - onTap: () { - Pfs.copy(key); - context.showSnackBar('${libL10n.copy} ${libL10n.success}'); - }, - ), + Btn.text( + text: key, + textStyle: UIs.text15, + onTap: () => _onTapTemperatureItem(key), + ).paddingSymmetric(horizontal: 5), Text('${val?.toStringAsFixed(1)}°C', style: UIs.text13Grey), ], ), @@ -813,41 +769,31 @@ class _ServerDetailPageState extends State child: Text(si.device), ); } + + final itemW = Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text(si.device, style: UIs.text15Bold), + UIs.width7, + Text('(${si.adapter.raw})', style: UIs.text13Grey), + ], + ), + Text(si.summary ?? '', style: UIs.text13Grey), + ], + )); + return InkWell( - onTap: () { - context.showRoundDialog( - title: si.device, - child: SingleChildScrollView( - child: SimpleMarkdown( - data: si.toMarkdown, - styleSheet: MarkdownStyleSheet( - tableBorder: TableBorder.all(color: Colors.grey), - tableHead: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - ), - ); - }, + onTap: () => _onTapSensorItem(si), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 7), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Text(si.device, style: UIs.text15Bold), - UIs.width7, - Text('(${si.adapter.raw})', style: UIs.text13Grey), - ], - ), - Text(si.summary ?? '', style: UIs.text13Grey), - ], - )), + itemW, UIs.width7, const Icon(Icons.keyboard_arrow_right, color: Colors.grey), ], @@ -869,7 +815,7 @@ class _ServerDetailPageState extends State ); } - Widget _buildCustom(Server si) { + Widget _buildCustomCmd(Server si) { final ss = si.status; if (ss.customCmds.isEmpty) return UIs.placeholder; return CardX( @@ -877,12 +823,12 @@ class _ServerDetailPageState extends State leading: const Icon(MingCute.command_line, size: 17), title: Text(l10n.customCmd), initiallyExpanded: _getInitExpand(ss.customCmds.length), - children: ss.customCmds.entries.map(_buildCustomItem).toList(), + children: ss.customCmds.entries.map(_buildCustomCmdItem).toList(), ), ); } - Widget _buildCustomItem(MapEntry cmd) { + Widget _buildCustomCmdItem(MapEntry cmd) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 7), child: KvRow( @@ -891,20 +837,7 @@ class _ServerDetailPageState extends State vBuilder: () { if (!cmd.value.contains('\n')) return null; return GestureDetector( - onTap: () { - context.showRoundDialog( - title: cmd.key, - child: SingleChildScrollView( - child: Text(cmd.value, style: UIs.text13Grey), - ), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text(libL10n.close), - ), - ], - ); - }, + onTap: () => _onTapCustomItem(cmd), child: const Icon( Icons.info_outline, size: 17, diff --git a/lib/view/page/server/edit.dart b/lib/view/page/server/edit.dart index ac000c2b..23aa815a 100644 --- a/lib/view/page/server/edit.dart +++ b/lib/view/page/server/edit.dart @@ -5,24 +5,25 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:icons_plus/icons_plus.dart'; import 'package:server_box/core/extension/context/locale.dart'; +import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/server/custom.dart'; import 'package:server_box/data/model/server/server.dart'; import 'package:server_box/data/model/server/wol_cfg.dart'; import 'package:server_box/data/provider/server.dart'; -import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/provider/private_key.dart'; import 'package:server_box/data/store/server.dart'; +import 'package:server_box/view/page/private_key/edit.dart'; class ServerEditPage extends StatefulWidget { - final Spi? args; + final SpiRequiredArgs? args; const ServerEditPage({super.key, this.args}); - static const route = AppRoute( + static const route = AppRoute( page: ServerEditPage.new, - path: '/server_edit', + path: '/servers/edit', ); @override @@ -30,7 +31,7 @@ class ServerEditPage extends StatefulWidget { } class _ServerEditPageState extends State with AfterLayoutMixin { - late final spi = widget.args; + late final spi = widget.args?.spi; final _nameController = TextEditingController(); final _ipController = TextEditingController(); final _altUrlController = TextEditingController(); @@ -187,14 +188,7 @@ class _ServerEditPageState extends State with AfterLayoutMixin { _buildJumpServer(), _buildMore(), ]; - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(17, 7, 17, 47), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: children, - ), - ); + return AutoMultiList(children: children); } Widget _buildAuth() { @@ -259,7 +253,10 @@ class _ServerEditPageState extends State with AfterLayoutMixin { ), trailing: Btn.icon( icon: const Icon(Icons.edit), - onTap: () => AppRoutes.keyEdit(pki: e).go(context), + onTap: () => PrivateKeyEditPage.route.go( + context, + args: PrivateKeyEditPageArgs(pki: e), + ), ), onTap: () => _keyIdx.value = index, ); @@ -269,23 +266,17 @@ class _ServerEditPageState extends State with AfterLayoutMixin { title: Text(libL10n.add), contentPadding: const EdgeInsets.only(left: 23, right: 23), trailing: const Icon(Icons.add), - onTap: () => AppRoutes.keyEdit().go(context), - ), - ); - return CardX( - child: ListenableBuilder( - listenable: _keyIdx, - builder: (_, __) => Column(children: tiles), + onTap: () => PrivateKeyEditPage.route.go(context), ), ); + return _keyIdx.listenVal((_) => Column(children: tiles)).cardx; }, ); } Widget _buildEnvs() { return _env.listenVal((val) { - final subtitle = - val.isEmpty ? null : Text(val.keys.join(','), style: UIs.textGrey); + final subtitle = val.isEmpty ? null : Text(val.keys.join(','), style: UIs.textGrey); return ListTile( leading: const Icon(HeroIcons.variable), subtitle: subtitle, @@ -419,18 +410,9 @@ class _ServerEditPageState extends State with AfterLayoutMixin { return ListTile( leading: const Icon(BoxIcons.bxs_file_json), title: const Text('JSON'), - subtitle: vals.isEmpty - ? null - : Text(vals.keys.join(','), style: UIs.textGrey), + subtitle: vals.isEmpty ? null : Text(vals.keys.join(','), style: UIs.textGrey), trailing: const Icon(Icons.keyboard_arrow_right), - onTap: () async { - final res = await KvEditor.route.go( - context, - KvEditorArgs(data: _customCmds.value), - ); - if (res == null) return; - _customCmds.value = res; - }, + onTap: _onTapCustomItem, ); }, ).cardx, @@ -535,153 +517,6 @@ class _ServerEditPageState extends State with AfterLayoutMixin { ).cardx; } - void _onSave() async { - if (_ipController.text.isEmpty) { - context.showSnackBar('${libL10n.empty} ${l10n.host}'); - return; - } - - if (_keyIdx.value == null && _passwordController.text.isEmpty) { - final cancel = await context.showRoundDialog( - title: libL10n.attention, - child: Text(libL10n.askContinue(l10n.useNoPwd)), - actions: [ - TextButton( - onPressed: () => context.pop(false), - child: Text(libL10n.ok), - ), - TextButton( - onPressed: () => context.pop(true), - child: Text(libL10n.cancel), - ) - ], - ); - if (cancel != false) return; - } - - // If [_pubKeyIndex] is -1, it means that the user has not selected - if (_keyIdx.value == -1) { - context.showSnackBar(libL10n.empty); - return; - } - if (_usernameController.text.isEmpty) { - _usernameController.text = 'root'; - } - if (_portController.text.isEmpty) { - _portController.text = '22'; - } - final customCmds = _customCmds.value; - final custom = ServerCustom( - pveAddr: _pveAddrCtrl.text.selfNotEmptyOrNull, - pveIgnoreCert: _pveIgnoreCert.value, - cmds: customCmds.isEmpty ? null : customCmds, - preferTempDev: _preferTempDevCtrl.text.selfNotEmptyOrNull, - logoUrl: _logoUrlCtrl.text.selfNotEmptyOrNull, - netDev: _netDevCtrl.text.selfNotEmptyOrNull, - scriptDir: _scriptDirCtrl.text.selfNotEmptyOrNull, - ); - - final wolEmpty = _wolMacCtrl.text.isEmpty && - _wolIpCtrl.text.isEmpty && - _wolPwdCtrl.text.isEmpty; - final wol = wolEmpty - ? null - : WakeOnLanCfg( - mac: _wolMacCtrl.text, - ip: _wolIpCtrl.text, - pwd: _wolPwdCtrl.text.selfNotEmptyOrNull, - ); - if (wol != null) { - final wolValidation = wol.validate(); - if (!wolValidation.$2) { - context.showSnackBar('${libL10n.fail}: ${wolValidation.$1}'); - return; - } - } - - final spi = Spi( - name: _nameController.text.isEmpty - ? _ipController.text - : _nameController.text, - ip: _ipController.text, - port: int.parse(_portController.text), - user: _usernameController.text, - pwd: _passwordController.text.selfNotEmptyOrNull, - keyId: _keyIdx.value != null - ? PrivateKeyProvider.pkis.value.elementAt(_keyIdx.value!).id - : null, - tags: _tags.value.isEmpty ? null : _tags.value.toList(), - alterUrl: _altUrlController.text.selfNotEmptyOrNull, - autoConnect: _autoConnect.value, - jumpId: _jumpServer.value, - custom: custom, - wolCfg: wol, - envs: _env.value.isEmpty ? null : _env.value, - ); - - if (this.spi == null) { - final existsIds = ServerStore.instance.box.keys; - if (existsIds.contains(spi.id)) { - context.showSnackBar('${l10n.sameIdServerExist}: ${spi.id}'); - return; - } - ServerProvider.addServer(spi); - } else { - ServerProvider.updateServer(this.spi!, spi); - } - - context.pop(); - } - - @override - void afterFirstLayout(BuildContext context) { - if (spi != null) { - _initWithSpi(spi!); - } - } - - void _initWithSpi(Spi spi) { - _nameController.text = spi.name; - _ipController.text = spi.ip; - _portController.text = spi.port.toString(); - _usernameController.text = spi.user; - if (spi.keyId == null) { - _passwordController.text = spi.pwd ?? ''; - } else { - _keyIdx.value = PrivateKeyProvider.pkis.value.indexWhere( - (e) => e.id == spi.keyId, - ); - } - - /// List in dart is passed by pointer, so you need to copy it here - _tags.value = spi.tags?.toSet() ?? {}; - - _altUrlController.text = spi.alterUrl ?? ''; - _autoConnect.value = spi.autoConnect; - _jumpServer.value = spi.jumpId; - - final custom = spi.custom; - if (custom != null) { - _pveAddrCtrl.text = custom.pveAddr ?? ''; - _pveIgnoreCert.value = custom.pveIgnoreCert; - _customCmds.value = custom.cmds ?? {}; - _preferTempDevCtrl.text = custom.preferTempDev ?? ''; - _logoUrlCtrl.text = custom.logoUrl ?? ''; - } - - final wol = spi.wolCfg; - if (wol != null) { - _wolMacCtrl.text = wol.mac; - _wolIpCtrl.text = wol.ip; - _wolPwdCtrl.text = wol.pwd ?? ''; - } - - _env.value = spi.envs ?? {}; - - _netDevCtrl.text = spi.custom?.netDev ?? ''; - _scriptDirCtrl.text = spi.custom?.scriptDir ?? ''; - } - Widget _buildWriteScriptTip() { return Btn.tile( text: libL10n.attention, @@ -742,4 +577,156 @@ class _ServerEditPageState extends State with AfterLayoutMixin { icon: const Icon(Icons.delete), ); } + + @override + void afterFirstLayout(BuildContext context) { + if (spi != null) { + _initWithSpi(spi!); + } + } +} + +extension on _ServerEditPageState { + void _onTapCustomItem() async { + final res = await KvEditor.route.go( + context, + KvEditorArgs(data: _customCmds.value), + ); + if (res == null) return; + _customCmds.value = res; + } + + void _onSave() async { + if (_ipController.text.isEmpty) { + context.showSnackBar('${libL10n.empty} ${l10n.host}'); + return; + } + + if (_keyIdx.value == null && _passwordController.text.isEmpty) { + final cancel = await context.showRoundDialog( + title: libL10n.attention, + child: Text(libL10n.askContinue(l10n.useNoPwd)), + actions: [ + TextButton( + onPressed: () => context.pop(false), + child: Text(libL10n.ok), + ), + TextButton( + onPressed: () => context.pop(true), + child: Text(libL10n.cancel), + ) + ], + ); + if (cancel != false) return; + } + + // If [_pubKeyIndex] is -1, it means that the user has not selected + if (_keyIdx.value == -1) { + context.showSnackBar(libL10n.empty); + return; + } + if (_usernameController.text.isEmpty) { + _usernameController.text = 'root'; + } + if (_portController.text.isEmpty) { + _portController.text = '22'; + } + final customCmds = _customCmds.value; + final custom = ServerCustom( + pveAddr: _pveAddrCtrl.text.selfNotEmptyOrNull, + pveIgnoreCert: _pveIgnoreCert.value, + cmds: customCmds.isEmpty ? null : customCmds, + preferTempDev: _preferTempDevCtrl.text.selfNotEmptyOrNull, + logoUrl: _logoUrlCtrl.text.selfNotEmptyOrNull, + netDev: _netDevCtrl.text.selfNotEmptyOrNull, + scriptDir: _scriptDirCtrl.text.selfNotEmptyOrNull, + ); + + final wolEmpty = _wolMacCtrl.text.isEmpty && _wolIpCtrl.text.isEmpty && _wolPwdCtrl.text.isEmpty; + final wol = wolEmpty + ? null + : WakeOnLanCfg( + mac: _wolMacCtrl.text, + ip: _wolIpCtrl.text, + pwd: _wolPwdCtrl.text.selfNotEmptyOrNull, + ); + if (wol != null) { + final wolValidation = wol.validate(); + if (!wolValidation.$2) { + context.showSnackBar('${libL10n.fail}: ${wolValidation.$1}'); + return; + } + } + + final spi = Spi( + name: _nameController.text.isEmpty ? _ipController.text : _nameController.text, + ip: _ipController.text, + port: int.parse(_portController.text), + user: _usernameController.text, + pwd: _passwordController.text.selfNotEmptyOrNull, + keyId: _keyIdx.value != null ? PrivateKeyProvider.pkis.value.elementAt(_keyIdx.value!).id : null, + tags: _tags.value.isEmpty ? null : _tags.value.toList(), + alterUrl: _altUrlController.text.selfNotEmptyOrNull, + autoConnect: _autoConnect.value, + jumpId: _jumpServer.value, + custom: custom, + wolCfg: wol, + envs: _env.value.isEmpty ? null : _env.value, + ); + + if (this.spi == null) { + final existsIds = ServerStore.instance.box.keys; + if (existsIds.contains(spi.id)) { + context.showSnackBar('${l10n.sameIdServerExist}: ${spi.id}'); + return; + } + ServerProvider.addServer(spi); + } else { + ServerProvider.updateServer(this.spi!, spi); + } + + context.pop(); + } + + void _initWithSpi(Spi spi) { + _nameController.text = spi.name; + _ipController.text = spi.ip; + _portController.text = spi.port.toString(); + _usernameController.text = spi.user; + if (spi.keyId == null) { + _passwordController.text = spi.pwd ?? ''; + } else { + _keyIdx.value = PrivateKeyProvider.pkis.value.indexWhere( + (e) => e.id == spi.keyId, + ); + } + + /// List in dart is passed by pointer, so you need to copy it here + _tags.value = spi.tags?.toSet() ?? {}; + + _altUrlController.text = spi.alterUrl ?? ''; + _autoConnect.value = spi.autoConnect; + _jumpServer.value = spi.jumpId; + + final custom = spi.custom; + if (custom != null) { + _pveAddrCtrl.text = custom.pveAddr ?? ''; + _pveIgnoreCert.value = custom.pveIgnoreCert; + _customCmds.value = custom.cmds ?? {}; + _preferTempDevCtrl.text = custom.preferTempDev ?? ''; + _logoUrlCtrl.text = custom.logoUrl ?? ''; + } + + final wol = spi.wolCfg; + if (wol != null) { + _wolMacCtrl.text = wol.mac; + _wolIpCtrl.text = wol.ip; + _wolPwdCtrl.text = wol.pwd ?? ''; + } + + _env.value = spi.envs ?? {}; + + _netDevCtrl.text = spi.custom?.netDev ?? ''; + _scriptDirCtrl.text = spi.custom?.scriptDir ?? ''; + } } diff --git a/lib/view/page/server/tab.dart b/lib/view/page/server/tab.dart deleted file mode 100644 index 95e71212..00000000 --- a/lib/view/page/server/tab.dart +++ /dev/null @@ -1,802 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; - -import 'package:fl_lib/fl_lib.dart'; -import 'package:flutter/material.dart'; -import 'package:icons_plus/icons_plus.dart'; -import 'package:server_box/core/extension/context/locale.dart'; -import 'package:server_box/core/extension/ssh_client.dart'; -import 'package:server_box/data/model/app/shell_func.dart'; -import 'package:server_box/data/model/server/try_limiter.dart'; -import 'package:server_box/data/res/build_data.dart'; -import 'package:server_box/data/res/store.dart'; -import 'package:server_box/view/page/server/edit.dart'; -import 'package:server_box/view/page/setting/entry.dart'; -import 'package:server_box/view/widget/percent_circle.dart'; - -import 'package:server_box/core/route.dart'; -import 'package:server_box/data/model/app/net_view.dart'; -import 'package:server_box/data/model/server/server.dart'; -import 'package:server_box/data/model/server/server_private_info.dart'; -import 'package:server_box/data/provider/server.dart'; -import 'package:server_box/view/widget/server_func_btns.dart'; - -part 'top_bar.dart'; - -class ServerPage extends StatefulWidget { - const ServerPage({super.key}); - - @override - State createState() => _ServerPageState(); -} - -const _cardPad = 74.0; -const _cardPadSingle = 13.0; - -class _ServerPageState extends State - with AutomaticKeepAliveClientMixin, AfterLayoutMixin { - late MediaQueryData _media; - - late double _textFactorDouble; - double _offset = 1; - late TextScaler _textFactor; - - final _cardsStatus = {}; - - Timer? _timer; - - final _tag = ''.vn; - bool _useDoubleColumn = false; - - final _scrollController = ScrollController(); - final _autoHideCtrl = AutoHideController(); - - @override - void dispose() { - super.dispose(); - _timer?.cancel(); - _scrollController.dispose(); - _autoHideCtrl.dispose(); - _tag.dispose(); - } - - @override - void initState() { - super.initState(); - if (!Stores.setting.fullScreenJitter.fetch()) return; - _timer = Timer.periodic(const Duration(seconds: 30), (_) { - if (mounted) { - _updateOffset(); - setState(() {}); - } else { - _timer?.cancel(); - } - }); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _media = MediaQuery.of(context); - _updateOffset(); - _updateTextScaler(); - _useDoubleColumn = _media.size.width > 639 && - Stores.setting.doubleColumnServersPage.fetch(); - } - - @override - Widget build(BuildContext context) { - super.build(context); - return OrientationBuilder(builder: (_, orientation) { - if (orientation == Orientation.landscape) { - final useFullScreen = Stores.setting.fullScreen.fetch(); - if (useFullScreen) return _buildLandscape(); - } - return _buildPortrait(); - }); - } - - Widget _buildPortrait() { - return Scaffold( - appBar: _TopBar( - tags: ServerProvider.tags, - onTagChanged: (p0) => _tag.value = p0, - initTag: _tag.value, - ), - body: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _autoHideCtrl.show(), - child: ListenableBuilder( - listenable: Stores.setting.textFactor.listenable(), - builder: (_, __) { - _updateTextScaler(); - return _buildBody(); - }, - ), - ), - floatingActionButton: AutoHide( - direction: AxisDirection.right, - offset: 75, - scrollController: _scrollController, - hideController: _autoHideCtrl, - child: FloatingActionButton( - heroTag: 'addServer', - onPressed: () => ServerEditPage.route.go(context), - tooltip: libL10n.add, - child: const Icon(Icons.add), - ), - ), - ); - } - - Widget _buildLandscape() { - final offset = Offset(_offset, _offset); - return Padding( - // Avoid display cutout - padding: EdgeInsets.all(_offset.abs()), - child: Transform.translate( - offset: offset, - child: Stack( - children: [ - _buildLandscapeBody(), - Positioned( - top: 0, - left: 0, - child: IconButton( - onPressed: () => SettingsPage.route.go(context), - icon: const Icon(Icons.settings, color: Colors.grey), - ), - ), - ], - ), - ), - ); - } - - Widget _buildLandscapeBody() { - return ServerProvider.serverOrder.listenVal((order) { - if (order.isEmpty) { - return Center( - child: Text(libL10n.empty, textAlign: TextAlign.center), - ); - } - - return PageView.builder( - itemCount: order.length, - itemBuilder: (_, idx) { - final id = order[idx]; - final srv = ServerProvider.pick(id: id); - if (srv == null) return UIs.placeholder; - - return srv.listenVal((srv) { - final title = _buildServerCardTitle(srv); - final List children = [ - title, - ..._buildNormalCard(srv.status, srv.spi).joinWith(SizedBox( - height: _media.size.height / 10, - )) - ]; - - return Padding( - padding: _media.padding, - child: ListenableBuilder( - listenable: _getCardNoti(id), - builder: (_, __) { - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: children, - ); - }, - ), - ); - }); - }, - ); - }); - } - - Widget _buildBody() { - return ServerProvider.serverOrder.listenVal( - (order) { - if (order.isEmpty) { - return Center( - child: Text(libL10n.empty, textAlign: TextAlign.center), - ); - } - - return _tag.listenVal( - (val) { - final filtered = _filterServers(order); - if (_useDoubleColumn && - Stores.setting.doubleColumnServersPage.fetch()) { - return _buildBodyMedium(filtered); - } - return _buildBodySmall(filtered: filtered); - }, - ); - }, - ); - } - - Widget _buildBodySmall({ - required List filtered, - EdgeInsets? padding = const EdgeInsets.fromLTRB(7, 0, 7, 7), - }) { - final count = filtered.length + 1; - return ListView.builder( - controller: _scrollController, - padding: padding, - itemCount: count, - itemBuilder: (_, index) { - // Issue #130 - if (index == count - 1) return UIs.height77; - final vnode = ServerProvider.pick(id: filtered[index]); - if (vnode == null) return UIs.placeholder; - return vnode.listenVal(_buildEachServerCard); - }, - ); - } - - Widget _buildBodyMedium(List filtered) { - final mid = (filtered.length / 2).ceil(); - final filteredLeft = filtered.sublist(0, mid); - final filteredRight = filtered.sublist(mid); - return Row( - children: [ - Expanded( - child: _buildBodySmall( - filtered: filteredLeft, - padding: const EdgeInsets.only(left: 7), - ), - ), - Expanded( - child: _buildBodySmall( - filtered: filteredRight, - padding: const EdgeInsets.only(right: 7), - ), - ) - ], - ); - } - - Widget _buildEachServerCard(Server? srv) { - if (srv == null) { - return UIs.placeholder; - } - - return CardX( - key: Key(srv.spi.id + _tag.value), - child: InkWell( - onTap: () { - if (srv.canViewDetails) { - AppRoutes.serverDetail(spi: srv.spi).go(context); - } else { - ServerEditPage.route.go(context, args: srv.spi); - } - }, - onLongPress: () { - if (srv.conn == ServerConn.finished) { - final id = srv.spi.id; - final cardStatus = _getCardNoti(id); - cardStatus.value = cardStatus.value.copyWith( - flip: !cardStatus.value.flip, - ); - } else { - ServerEditPage.route.go(context, args: srv.spi); - } - }, - child: Padding( - padding: const EdgeInsets.only( - left: _cardPadSingle, - right: 3, - top: _cardPadSingle, - bottom: _cardPadSingle, - ), - child: _buildRealServerCard(srv), - ), - ), - ); - } - - /// The child's width mat not equal to 1/4 of the screen width, - /// so we need to wrap it with a SizedBox. - Widget _wrapWithSizedbox(Widget child, [bool circle = false]) { - var width = (_media.size.width - _cardPad) / (circle ? 4 : 4.3); - if (_useDoubleColumn) width /= 2; - return SizedBox( - width: width, - child: child, - ); - } - - Widget _buildRealServerCard(Server srv) { - final id = srv.spi.id; - final cardStatus = _getCardNoti(id); - final title = _buildServerCardTitle(srv); - - return cardStatus.listenVal((_) { - final List children = [title]; - if (srv.conn == ServerConn.finished) { - if (cardStatus.value.flip) { - children.add(_buildFlippedCard(srv)); - } else { - children.addAll(_buildNormalCard(srv.status, srv.spi)); - } - } - - final height = _calcCardHeight(srv.conn, cardStatus.value.flip); - return AnimatedContainer( - duration: const Duration(milliseconds: 377), - curve: Curves.fastEaseInToSlowEaseOut, - height: height, - // Use [OverflowBox] to dismiss the warning of [Column] overflow. - child: OverflowBox( - // If `height == _kCardHeightMin`, the `maxHeight` will be ignored. - // - // You can comment the `maxHeight` then connect&disconnect the server - // to see the difference. - maxHeight: height != _kCardHeightMin ? height : null, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: children, - ), - ), - ); - }); - } - - Widget _buildFlippedCard(Server srv) { - const color = Colors.grey; - const textStyle = TextStyle(fontSize: 13, color: color); - final children = [ - Btn.column( - onTap: () => _askFor( - func: () async { - if (Stores.setting.showSuspendTip.fetch()) { - await context.showRoundDialog( - title: libL10n.attention, - child: Text(l10n.suspendTip), - ); - Stores.setting.showSuspendTip.put(false); - } - srv.client?.execWithPwd( - ShellFunc.suspend.exec(srv.spi.id), - context: context, - id: srv.id, - ); - }, - typ: l10n.suspend, - name: srv.spi.name, - ), - icon: const Icon(Icons.stop, color: color), - text: l10n.suspend, - textStyle: textStyle, - ), - Btn.column( - onTap: () => _askFor( - func: () => srv.client?.execWithPwd( - ShellFunc.shutdown.exec(srv.spi.id), - context: context, - id: srv.id, - ), - typ: l10n.shutdown, - name: srv.spi.name, - ), - icon: const Icon(Icons.power_off, color: color), - text: l10n.shutdown, - textStyle: textStyle, - ), - Btn.column( - onTap: () => _askFor( - func: () => srv.client?.execWithPwd( - ShellFunc.reboot.exec(srv.spi.id), - context: context, - id: srv.id, - ), - typ: l10n.reboot, - name: srv.spi.name, - ), - icon: const Icon(Icons.restart_alt, color: color), - text: l10n.reboot, - textStyle: textStyle, - ), - Btn.column( - onTap: () => ServerEditPage.route.go(context, args: srv.spi), - icon: const Icon(Icons.edit, color: color), - text: libL10n.edit, - textStyle: textStyle, - ) - ]; - - final width = (_media.size.width - _cardPad) / children.length; - return Padding( - padding: const EdgeInsets.only(top: 9), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: children.map((e) { - if (width == 0) return e; - return SizedBox(width: width, child: e); - }).toList(), - ), - ); - } - - List _buildNormalCard(ServerStatus ss, Spi spi) { - return [ - UIs.height13, - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _wrapWithSizedbox(PercentCircle(percent: ss.cpu.usedPercent()), true), - _wrapWithSizedbox( - PercentCircle(percent: ss.mem.usedPercent * 100), - true, - ), - _wrapWithSizedbox(_buildNet(ss, spi.id)), - _wrapWithSizedbox(_buildDisk(ss, spi.id)), - ], - ), - UIs.height13, - if (Stores.setting.moveServerFuncs.fetch() && - // Discussion #146 - !Stores.setting.serverTabUseOldUI.fetch()) - SizedBox( - height: 27, - child: ServerFuncBtns(spi: spi), - ), - ]; - } - - Widget _buildServerCardTitle(Server s) { - return Padding( - padding: const EdgeInsets.only(left: 7, right: 13), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ConstrainedBox( - constraints: BoxConstraints(maxWidth: _media.size.width / 2.3), - child: Hero( - tag: 'home_card_title_${s.spi.id}', - transitionOnUserGestures: true, - child: Material( - color: Colors.transparent, - child: Text( - s.spi.name, - style: UIs.text13Bold.copyWith( - color: context.isDark ? Colors.white : Colors.black, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - const Icon( - Icons.keyboard_arrow_right, - size: 17, - color: Colors.grey, - ), - const Spacer(), - _buildTopRightText(s), - _buildTopRightWidget(s), - ], - ), - ); - } - - Widget _buildTopRightWidget(Server s) { - final (child, onTap) = switch (s.conn) { - ServerConn.connecting || ServerConn.loading || ServerConn.connected => ( - SizedBox( - width: 19, - height: 19, - child: CircularProgressIndicator( - strokeWidth: 3, - valueColor: AlwaysStoppedAnimation(UIs.primaryColor), - ), - ), - null, - ), - ServerConn.failed => ( - const Icon(Icons.refresh, size: 21, color: Colors.grey), - () { - TryLimiter.reset(s.spi.id); - ServerProvider.refresh(spi: s.spi); - }, - ), - ServerConn.disconnected => ( - const Icon(MingCute.link_3_line, size: 19, color: Colors.grey), - () => ServerProvider.refresh(spi: s.spi) - ), - ServerConn.finished => ( - const Icon(MingCute.unlink_2_line, size: 17, color: Colors.grey), - () => ServerProvider.closeServer(id: s.spi.id), - ), - }; - - // Or the loading icon will be rescaled. - final wrapped = child is SizedBox - ? child - : SizedBox(height: _kCardHeightMin, width: 27, child: child); - if (onTap == null) return wrapped.paddingOnly(left: 10); - return InkWell( - borderRadius: BorderRadius.circular(7), - onTap: onTap, - child: wrapped, - ).paddingOnly(left: 5); - } - - Widget _buildTopRightText(Server s) { - final hasErr = s.conn == ServerConn.failed && s.status.err != null; - final str = s.getTopRightStr(s.spi); - if (str == null) return UIs.placeholder; - return GestureDetector( - onTap: () { - if (!hasErr) return; - _showFailReason(s.status); - }, - child: Text(str, style: UIs.text13Grey), - ); - } - - void _showFailReason(ServerStatus ss) { - final md = ''' -${ss.err?.solution ?? l10n.unknown} - -```sh -${ss.err?.message ?? 'null'} -'''; - context.showRoundDialog( - title: libL10n.error, - child: SingleChildScrollView(child: SimpleMarkdown(data: md)), - actions: [ - TextButton( - onPressed: () => Pfs.copy(md), - child: Text(libL10n.copy), - ) - ], - ); - } - - Widget _buildDisk(ServerStatus ss, String id) { - final cardNoti = _getCardNoti(id); - return ListenableBuilder( - listenable: cardNoti, - builder: (_, __) { - final isSpeed = cardNoti.value.diskIO ?? - !Stores.setting.serverTabPreferDiskAmount.fetch(); - - final (r, w) = ss.diskIO.cachedAllSpeed; - - return AnimatedSwitcher( - duration: const Duration(milliseconds: 377), - transitionBuilder: (Widget child, Animation animation) { - return FadeTransition(opacity: animation, child: child); - }, - child: _buildIOData( - isSpeed - ? '${l10n.read}:\n$r' - : 'Total:\n${ss.diskUsage?.size.kb2Str}', - isSpeed - ? '${l10n.write}:\n$w' - : 'Used:\n${ss.diskUsage?.used.kb2Str}', - onTap: () { - cardNoti.value = cardNoti.value.copyWith(diskIO: !isSpeed); - }, - key: ValueKey(isSpeed), - ), - ); - }, - ); - } - - Widget _buildNet(ServerStatus ss, String id) { - final cardNoti = _getCardNoti(id); - final type = cardNoti.value.net ?? Stores.setting.netViewType.fetch(); - final device = ServerProvider.pick(id: id)?.value.spi.custom?.netDev; - final (a, b) = type.build(ss, dev: device); - return AnimatedSwitcher( - duration: const Duration(milliseconds: 377), - transitionBuilder: (c, anim) => FadeTransition(opacity: anim, child: c), - child: _buildIOData( - a, - b, - onTap: () => cardNoti.value = cardNoti.value.copyWith(net: type.next), - key: ValueKey(type), - ), - ); - } - - Widget _buildIOData( - String up, - String down, { - void Function()? onTap, - Key? key, - }) { - final child = Column( - children: [ - Text( - up, - style: const TextStyle(fontSize: 10, color: Colors.grey), - textAlign: TextAlign.center, - textScaler: _textFactor, - ), - const SizedBox(height: 3), - Text( - down, - style: const TextStyle(fontSize: 10, color: Colors.grey), - textAlign: TextAlign.center, - textScaler: _textFactor, - ) - ], - ); - if (onTap == null) return child; - return IconButton( - key: key, - padding: const EdgeInsets.symmetric(horizontal: 3), - onPressed: onTap, - icon: child, - ); - } - - @override - bool get wantKeepAlive => true; - - @override - Future afterFirstLayout(BuildContext context) async { - ServerProvider.refresh(); - ServerProvider.startAutoRefresh(); - } - - List _filterServers(List order) { - final tag = _tag.value; - if (tag == TagSwitcher.kDefaultTag) return order; - return order.where((e) { - final tags = ServerProvider.pick(id: e)?.value.spi.tags; - if (tags == null) return false; - return tags.contains(tag); - }).toList(); - } - - static const _kCardHeightMin = 23.0; - static const _kCardHeightFlip = 99.0; - static const _kCardHeightNormal = 108.0; - static const _kCardHeightMoveOutFuncs = 135.0; - - double? _calcCardHeight(ServerConn cs, bool flip) { - if (_textFactorDouble != 1.0) return null; - if (cs != ServerConn.finished) { - return _kCardHeightMin; - } - if (flip) { - return _kCardHeightFlip; - } - if (Stores.setting.moveServerFuncs.fetch() && - // Discussion #146 - !Stores.setting.serverTabUseOldUI.fetch()) { - return _kCardHeightMoveOutFuncs; - } - return _kCardHeightNormal; - } - - void _askFor({ - required void Function() func, - required String typ, - required String name, - }) { - context.showRoundDialog( - title: libL10n.attention, - child: Text(libL10n.askContinue('$typ ${l10n.server}($name)')), - actions: Btn.ok( - onTap: () { - context.pop(); - func(); - }, - ).toList, - ); - } - - _CardNotifier _getCardNoti(String id) => _cardsStatus.putIfAbsent( - id, - () => _CardNotifier(const _CardStatus()), - ); - - void _updateOffset() { - if (!Stores.setting.fullScreenJitter.fetch()) return; - final x = _media.size.height * 0.03; - final r = math.Random().nextDouble(); - final n = math.Random().nextBool() ? 1 : -1; - _offset = x * r * n; - } - - void _updateTextScaler() { - _textFactorDouble = Stores.setting.textFactor.fetch(); - _textFactor = TextScaler.linear(_textFactorDouble); - } -} - -typedef _CardNotifier = ValueNotifier<_CardStatus>; - -class _CardStatus { - final bool flip; - final bool? diskIO; - final NetViewType? net; - - const _CardStatus({ - this.flip = false, - this.diskIO, - this.net, - }); - - _CardStatus copyWith({ - bool? flip, - bool? diskIO, - NetViewType? net, - }) { - return _CardStatus( - flip: flip ?? this.flip, - diskIO: diskIO ?? this.diskIO, - net: net ?? this.net, - ); - } -} - -extension _ServerX on Server { - String? getTopRightStr(Spi spi) { - switch (conn) { - case ServerConn.disconnected: - return null; - case ServerConn.finished: - // Highest priority of temperature display - final cmdTemp = () { - final val = status.customCmds['server_card_top_right']; - if (val == null) return null; - // This returned value is used on server card top right, so it should - // be a single line string. - return val.split('\n').lastOrNull; - }(); - final temperatureVal = () { - // Second priority - final preferTempDev = spi.custom?.preferTempDev; - if (preferTempDev != null) { - final preferTemp = status.sensors - .firstWhereOrNull((e) => e.device == preferTempDev) - ?.summary - ?.split(' ') - .firstOrNull; - if (preferTemp != null) { - return double.tryParse(preferTemp.replaceFirst('°C', '')); - } - } - // Last priority - final temp = status.temps.first; - if (temp != null) { - return temp; - } - return null; - }(); - final upTime = status.more[StatusCmdType.uptime]; - final items = [ - cmdTemp ?? - (temperatureVal != null - ? '${temperatureVal.toStringAsFixed(1)}°C' - : null), - upTime - ]; - final str = items.where((e) => e != null && e.isNotEmpty).join(' | '); - if (str.isEmpty) return libL10n.empty; - return str; - case ServerConn.loading: - return null; - case ServerConn.connected: - return null; - case ServerConn.connecting: - return null; - case ServerConn.failed: - return status.err != null ? l10n.viewErr : libL10n.fail; - } - } -} diff --git a/lib/view/page/server/tab/card_stat.dart b/lib/view/page/server/tab/card_stat.dart new file mode 100644 index 00000000..809645ea --- /dev/null +++ b/lib/view/page/server/tab/card_stat.dart @@ -0,0 +1,27 @@ +part of 'tab.dart'; + +typedef _CardNotifier = ValueNotifier<_CardStatus>; + +class _CardStatus { + final bool flip; + final bool? diskIO; + final NetViewType? net; + + const _CardStatus({ + this.flip = false, + this.diskIO, + this.net, + }); + + _CardStatus copyWith({ + bool? flip, + bool? diskIO, + NetViewType? net, + }) { + return _CardStatus( + flip: flip ?? this.flip, + diskIO: diskIO ?? this.diskIO, + net: net ?? this.net, + ); + } +} diff --git a/lib/view/page/server/tab/content.dart b/lib/view/page/server/tab/content.dart new file mode 100644 index 00000000..6edfb149 --- /dev/null +++ b/lib/view/page/server/tab/content.dart @@ -0,0 +1,195 @@ +part of 'tab.dart'; + +extension on _ServerPageState { + Widget _buildServerCardTitle(Server s) { + return Padding( + padding: const EdgeInsets.only(left: 7, right: 13), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ConstrainedBox( + constraints: BoxConstraints(maxWidth: _media.size.width / 2.3), + child: Hero( + tag: 'home_card_title_${s.spi.id}', + transitionOnUserGestures: true, + child: Material( + color: Colors.transparent, + child: Text( + s.spi.name, + style: UIs.text13Bold.copyWith( + color: context.isDark ? Colors.white : Colors.black, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + const Icon( + Icons.keyboard_arrow_right, + size: 17, + color: Colors.grey, + ), + const Spacer(), + _buildTopRightText(s), + _buildTopRightWidget(s), + ], + ), + ); + } + + Widget _buildTopRightWidget(Server s) { + final (child, onTap) = switch (s.conn) { + ServerConn.connecting || ServerConn.loading || ServerConn.connected => ( + SizedBox( + width: 19, + height: 19, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(UIs.primaryColor), + ), + ), + null, + ), + ServerConn.failed => ( + const Icon(Icons.refresh, size: 21, color: Colors.grey), + () { + TryLimiter.reset(s.spi.id); + ServerProvider.refresh(spi: s.spi); + }, + ), + ServerConn.disconnected => ( + const Icon(MingCute.link_3_line, size: 19, color: Colors.grey), + () => ServerProvider.refresh(spi: s.spi) + ), + ServerConn.finished => ( + const Icon(MingCute.unlink_2_line, size: 17, color: Colors.grey), + () => ServerProvider.closeServer(id: s.spi.id), + ), + }; + + // Or the loading icon will be rescaled. + final wrapped = child is SizedBox + ? child + : SizedBox(height: _ServerPageState._kCardHeightMin, width: 27, child: child); + if (onTap == null) return wrapped.paddingOnly(left: 10); + return InkWell( + borderRadius: BorderRadius.circular(7), + onTap: onTap, + child: wrapped, + ).paddingOnly(left: 5); + } + + Widget _buildTopRightText(Server s) { + final hasErr = s.conn == ServerConn.failed && s.status.err != null; + final str = s._getTopRightStr(s.spi); + if (str == null) return UIs.placeholder; + return GestureDetector( + onTap: () { + if (!hasErr) return; + _showFailReason(s.status); + }, + child: Text(str, style: UIs.text13Grey), + ); + } + + void _showFailReason(ServerStatus ss) { + final md = ''' +${ss.err?.solution ?? l10n.unknown} + +```sh +${ss.err?.message ?? 'null'} +'''; + context.showRoundDialog( + title: libL10n.error, + child: SingleChildScrollView(child: SimpleMarkdown(data: md)), + actions: [ + TextButton( + onPressed: () => Pfs.copy(md), + child: Text(libL10n.copy), + ) + ], + ); + } + + Widget _buildDisk(ServerStatus ss, String id) { + final cardNoti = _getCardNoti(id); + return ListenableBuilder( + listenable: cardNoti, + builder: (_, __) { + final isSpeed = cardNoti.value.diskIO ?? !Stores.setting.serverTabPreferDiskAmount.fetch(); + + final (r, w) = ss.diskIO.cachedAllSpeed; + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 377), + transitionBuilder: (Widget child, Animation animation) { + return FadeTransition(opacity: animation, child: child); + }, + child: _buildIOData( + isSpeed ? '${l10n.read}:\n$r' : 'Total:\n${ss.diskUsage?.size.kb2Str}', + isSpeed ? '${l10n.write}:\n$w' : 'Used:\n${ss.diskUsage?.used.kb2Str}', + onTap: () { + cardNoti.value = cardNoti.value.copyWith(diskIO: !isSpeed); + }, + key: ValueKey(isSpeed), + ), + ); + }, + ); + } + + Widget _buildNet(ServerStatus ss, String id) { + final cardNoti = _getCardNoti(id); + final type = cardNoti.value.net ?? Stores.setting.netViewType.fetch(); + final device = ServerProvider.pick(id: id)?.value.spi.custom?.netDev; + final (a, b) = type.build(ss, dev: device); + return AnimatedSwitcher( + duration: const Duration(milliseconds: 377), + transitionBuilder: (c, anim) => FadeTransition(opacity: anim, child: c), + child: _buildIOData( + a, + b, + onTap: () => cardNoti.value = cardNoti.value.copyWith(net: type.next), + key: ValueKey(type), + ), + ); + } + + Widget _buildIOData( + String up, + String down, { + void Function()? onTap, + Key? key, + int maxLines = 2 + }) { + final child = Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + up, + style: const TextStyle(fontSize: 10, color: Colors.grey), + textAlign: TextAlign.center, + textScaler: _textFactor, + maxLines: maxLines, + ), + const SizedBox(height: 3), + Text( + down, + style: const TextStyle(fontSize: 10, color: Colors.grey), + textAlign: TextAlign.center, + textScaler: _textFactor, + maxLines: maxLines, + ) + ], + ); + if (onTap == null) return child; + return IconButton( + key: key, + padding: const EdgeInsets.symmetric(horizontal: 3), + onPressed: onTap, + icon: child, + ); + } +} diff --git a/lib/view/page/server/tab/landscape.dart b/lib/view/page/server/tab/landscape.dart new file mode 100644 index 00000000..3e00606c --- /dev/null +++ b/lib/view/page/server/tab/landscape.dart @@ -0,0 +1,69 @@ +part of 'tab.dart'; + +extension on _ServerPageState { + Widget _buildLandscape() { + final offset = Offset(_offset, _offset); + return Padding( + // Avoid display cutout + padding: EdgeInsets.all(_offset.abs()), + child: Transform.translate( + offset: offset, + child: Stack( + children: [ + _buildLandscapeBody(), + Positioned( + top: 0, + left: 0, + child: IconButton( + onPressed: () => SettingsPage.route.go(context), + icon: const Icon(Icons.settings, color: Colors.grey), + ), + ), + ], + ), + ), + ); + } + + Widget _buildLandscapeBody() { + return ServerProvider.serverOrder.listenVal((order) { + if (order.isEmpty) { + return Center( + child: Text(libL10n.empty, textAlign: TextAlign.center), + ); + } + + return PageView.builder( + itemCount: order.length, + itemBuilder: (_, idx) { + final id = order[idx]; + final srv = ServerProvider.pick(id: id); + if (srv == null) return UIs.placeholder; + + return srv.listenVal((srv) { + final title = _buildServerCardTitle(srv); + final List children = [ + title, + _buildNormalCard(srv.status, srv.spi), + ]; + + return Padding( + padding: _media.padding, + child: ListenableBuilder( + listenable: _getCardNoti(id), + builder: (_, __) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: children, + ); + }, + ), + ); + }); + }, + ); + }); + } +} diff --git a/lib/view/page/server/tab/tab.dart b/lib/view/page/server/tab/tab.dart new file mode 100644 index 00000000..08f46df0 --- /dev/null +++ b/lib/view/page/server/tab/tab.dart @@ -0,0 +1,369 @@ +// ignore_for_file: invalid_use_of_protected_member + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter/material.dart'; +import 'package:icons_plus/icons_plus.dart'; +import 'package:responsive_framework/responsive_framework.dart'; +import 'package:server_box/core/extension/context/locale.dart'; +import 'package:server_box/core/extension/ssh_client.dart'; +import 'package:server_box/core/route.dart'; +import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/server/try_limiter.dart'; +import 'package:server_box/data/res/build_data.dart'; +import 'package:server_box/data/res/store.dart'; +import 'package:server_box/view/page/server/detail/view.dart'; +import 'package:server_box/view/page/server/edit.dart'; +import 'package:server_box/view/page/setting/entry.dart'; +import 'package:server_box/view/widget/percent_circle.dart'; + +import 'package:server_box/data/model/app/net_view.dart'; +import 'package:server_box/data/model/server/server.dart'; +import 'package:server_box/data/model/server/server_private_info.dart'; +import 'package:server_box/data/provider/server.dart'; +import 'package:server_box/view/widget/server_func_btns.dart'; + +part 'top_bar.dart'; +part 'card_stat.dart'; +part 'utils.dart'; +part 'content.dart'; +part 'landscape.dart'; + +class ServerPage extends StatefulWidget { + const ServerPage({super.key}); + + @override + State createState() => _ServerPageState(); + + static const route = AppRouteNoArg( + page: ServerPage.new, + path: '/servers', + ); +} + +const _cardPad = 74.0; +const _cardPadSingle = 13.0; + +class _ServerPageState extends State with AutomaticKeepAliveClientMixin, AfterLayoutMixin { + late MediaQueryData _media; + + late double _textFactorDouble; + double _offset = 1; + late TextScaler _textFactor; + + final _cardsStatus = {}; + + Timer? _timer; + + final _tag = ''.vn; + + final _scrollController = ScrollController(); + final _autoHideCtrl = AutoHideController(); + final _splitViewCtrl = SplitViewController(); + + @override + void dispose() { + super.dispose(); + _timer?.cancel(); + _scrollController.dispose(); + _autoHideCtrl.dispose(); + _tag.dispose(); + } + + @override + void initState() { + super.initState(); + _startAvoidJitterTimer(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _media = MediaQuery.of(context); + _updateOffset(); + _updateTextScaler(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return OrientationBuilder(builder: (_, orientation) { + if (orientation == Orientation.landscape) { + final useFullScreen = Stores.setting.fullScreen.fetch(); + // Only enter landscape mode when the screen is wide enough and the + // full screen mode is enabled. + if (useFullScreen) return _buildLandscape(); + } + return _buildPortrait(); + }); + } + + Widget _buildScaffold(Widget child) { + return Scaffold( + appBar: _TopBar( + tags: ServerProvider.tags, + onTagChanged: (p0) => _tag.value = p0, + initTag: _tag.value, + ), + body: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _autoHideCtrl.show(), + child: ListenableBuilder( + listenable: Stores.setting.textFactor.listenable(), + builder: (_, __) { + _updateTextScaler(); + return child; + }, + ), + ), + floatingActionButton: AutoHide( + direction: AxisDirection.right, + offset: 75, + scrollController: _scrollController, + hideController: _autoHideCtrl, + child: FloatingActionButton( + heroTag: 'addServer', + onPressed: _onTapAddServer, + tooltip: libL10n.add, + child: const Icon(Icons.add), + ), + ), + ); + } + + Widget _buildPortrait() { + // final isMobile = ResponsiveBreakpoints.of(context).isMobile; + return ServerProvider.serverOrder.listenVal( + (order) { + return _tag.listenVal( + (val) { + final filtered = _filterServers(order); + final child = _buildScaffold(_buildBodySmall(filtered: filtered)); + // if (isMobile) { + return child; + // } + + // return SplitView( + // controller: _splitViewCtrl, + // leftWeight: 1, + // rightWeight: 1.3, + // initialRight: Center(child: CircularProgressIndicator()), + // leftBuilder: (_, __) => child, + // ); + }, + ); + }, + ); + } + + Widget _buildBodySmall({ + required List filtered, + EdgeInsets? padding = const EdgeInsets.fromLTRB(7, 0, 7, 7), + }) { + if (filtered.isEmpty) { + return Center(child: Text(libL10n.empty, textAlign: TextAlign.center)); + } + + // Calculate number of columns based on available width + final columnsCount = math.max(1, (_media.size.width / UIs.columnWidth).floor()); + + // Calculate number of rows needed + final rowCount = (filtered.length + columnsCount - 1) ~/ columnsCount; + + return ListView.builder( + controller: _scrollController, + padding: padding, + itemCount: rowCount + 1, // +1 for the bottom space + itemBuilder: (_, rowIndex) { + // Bottom space + if (rowIndex == rowCount) return UIs.height77; + + // Create a row of server cards + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(columnsCount, (colIndex) { + final index = rowIndex * columnsCount + colIndex; + if (index >= filtered.length) return Expanded(child: Container()); + + final vnode = ServerProvider.pick(id: filtered[index]); + if (vnode == null) return Expanded(child: UIs.placeholder); + + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: vnode.listenVal(_buildEachServerCard), + ), + ); + }), + ), + ); + }, + ); + } + + Widget _buildEachServerCard(Server? srv) { + if (srv == null) { + return UIs.placeholder; + } + + return CardX( + key: Key(srv.spi.id + _tag.value), + child: InkWell( + onTap: () => _onTapCard(srv), + onLongPress: () => _onLongPressCard(srv), + child: Padding( + padding: const EdgeInsets.only( + left: _cardPadSingle, + right: 3, + top: _cardPadSingle, + bottom: _cardPadSingle, + ), + child: _buildRealServerCard(srv), + ), + ), + ); + } + + /// The child's width mat not equal to 1/4 of the screen width, + /// so we need to wrap it with a SizedBox. + Widget _wrapWithSizedbox(Widget child, double maxWidth, [bool circle = false]) { + return LayoutBuilder(builder: (_, cons) { + final width = (maxWidth - _cardPad) / 4; + return SizedBox( + width: width, + child: child, + ); + }); + } + + Widget _buildRealServerCard(Server srv) { + final id = srv.spi.id; + final cardStatus = _getCardNoti(id); + final title = _buildServerCardTitle(srv); + + return cardStatus.listenVal((_) { + final List children = [title]; + if (srv.conn == ServerConn.finished) { + if (cardStatus.value.flip) { + children.add(_buildFlippedCard(srv)); + } else { + children.add(_buildNormalCard(srv.status, srv.spi)); + } + } + + final height = _calcCardHeight(srv.conn, cardStatus.value.flip); + return AnimatedContainer( + duration: const Duration(milliseconds: 377), + curve: Curves.fastEaseInToSlowEaseOut, + height: height, + // Use [OverflowBox] to dismiss the warning of [Column] overflow. + child: OverflowBox( + // If `height == _kCardHeightMin`, the `maxHeight` will be ignored. + // + // You can comment the `maxHeight` then connect&disconnect the server + // to see the difference. + maxHeight: height != _kCardHeightMin ? height : null, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: children, + ), + ), + ); + }); + } + + Widget _buildFlippedCard(Server srv) { + const color = Colors.grey; + const textStyle = TextStyle(fontSize: 13, color: color); + final children = [ + Btn.column( + onTap: () => _onTapSuspend(srv), + icon: const Icon(Icons.stop, color: color), + text: l10n.suspend, + textStyle: textStyle, + ), + Btn.column( + onTap: () => _onTapShutdown(srv), + icon: const Icon(Icons.power_off, color: color), + text: l10n.shutdown, + textStyle: textStyle, + ), + Btn.column( + onTap: () => _onTapReboot(srv), + icon: const Icon(Icons.restart_alt, color: color), + text: l10n.reboot, + textStyle: textStyle, + ), + Btn.column( + onTap: () => _onTapEdit(srv), + icon: const Icon(Icons.edit, color: color), + text: libL10n.edit, + textStyle: textStyle, + ) + ]; + + final width = (_media.size.width - _cardPad) / children.length; + return Padding( + padding: const EdgeInsets.only(top: 9), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: children.map((e) { + if (width == 0) return e; + return SizedBox(width: width, child: e); + }).toList(), + ), + ); + } + + Widget _buildNormalCard(ServerStatus ss, Spi spi) { + return LayoutBuilder(builder: (_, cons) { + final maxWidth = cons.maxWidth; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + UIs.height13, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _wrapWithSizedbox(PercentCircle(percent: ss.cpu.usedPercent()), maxWidth, true), + _wrapWithSizedbox( + PercentCircle(percent: ss.mem.usedPercent * 100), + maxWidth, + true, + ), + _wrapWithSizedbox(_buildNet(ss, spi.id), maxWidth), + _wrapWithSizedbox(_buildDisk(ss, spi.id), maxWidth), + ], + ), + UIs.height13, + if (Stores.setting.moveServerFuncs.fetch() && + // Discussion #146 + !Stores.setting.serverTabUseOldUI.fetch()) + SizedBox( + height: 27, + child: ServerFuncBtns(spi: spi), + ), + ], + ); + }); + } + + @override + bool get wantKeepAlive => true; + + @override + Future afterFirstLayout(BuildContext context) async { + ServerProvider.refresh(); + ServerProvider.startAutoRefresh(); + } + + static const _kCardHeightMin = 23.0; + static const _kCardHeightFlip = 99.0; + static const _kCardHeightNormal = 108.0; + static const _kCardHeightMoveOutFuncs = 135.0; +} diff --git a/lib/view/page/server/top_bar.dart b/lib/view/page/server/tab/top_bar.dart similarity index 93% rename from lib/view/page/server/top_bar.dart rename to lib/view/page/server/tab/top_bar.dart index 28d5484e..61180383 100644 --- a/lib/view/page/server/top_bar.dart +++ b/lib/view/page/server/tab/top_bar.dart @@ -13,6 +13,9 @@ final class _TopBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { + final isMobile = ResponsiveBreakpoints.of(context).isMobile; + if (!isMobile) return UIs.placeholder; + return Padding( padding: const EdgeInsets.only(left: 10), child: Row( diff --git a/lib/view/page/server/tab/utils.dart b/lib/view/page/server/tab/utils.dart new file mode 100644 index 00000000..fb32791a --- /dev/null +++ b/lib/view/page/server/tab/utils.dart @@ -0,0 +1,243 @@ +// ignore_for_file: invalid_use_of_protected_member + +part of 'tab.dart'; + +extension _Actions on _ServerPageState { + void _onTapCard(Server srv) { + if (srv.canViewDetails) { + // _splitViewCtrl.replace(ServerDetailPage( + // key: ValueKey(srv.spi.id), + // args: SpiRequiredArgs(srv.spi), + // )); + ServerDetailPage.route.go( + context, + SpiRequiredArgs(srv.spi), + ); + } else { + // _splitViewCtrl.replace(ServerEditPage( + // key: ValueKey(srv.spi.id), + // args: SpiRequiredArgs(srv.spi), + // )); + ServerEditPage.route.go( + context, + args: SpiRequiredArgs(srv.spi), + ); + } + } + + void _onLongPressCard(Server srv) { + if (srv.conn == ServerConn.finished) { + final id = srv.spi.id; + final cardStatus = _getCardNoti(id); + cardStatus.value = cardStatus.value.copyWith( + flip: !cardStatus.value.flip, + ); + } else { + _splitViewCtrl.replace(ServerEditPage( + key: ValueKey(srv.spi.id), + args: SpiRequiredArgs(srv.spi), + )); + } + } + + void _onTapAddServer() { + // final isMobile = ResponsiveBreakpoints.of(context).isMobile; + // if (isMobile) { + ServerEditPage.route.go(context); + // } else { + // _splitViewCtrl.replace(const ServerEditPage( + // key: ValueKey('addServer'), + // )); + // } + } +} + +extension _Operation on _ServerPageState { + void _onTapSuspend(Server srv) { + _askFor( + func: () async { + if (Stores.setting.showSuspendTip.fetch()) { + await context.showRoundDialog( + title: libL10n.attention, + child: Text(l10n.suspendTip), + ); + Stores.setting.showSuspendTip.put(false); + } + srv.client?.execWithPwd( + ShellFunc.suspend.exec(srv.spi.id), + context: context, + id: srv.id, + ); + }, + typ: l10n.suspend, + name: srv.spi.name, + ); + } + + void _onTapShutdown(Server srv) { + _askFor( + func: () => srv.client?.execWithPwd( + ShellFunc.shutdown.exec(srv.spi.id), + context: context, + id: srv.id, + ), + typ: l10n.shutdown, + name: srv.spi.name, + ); + } + + void _onTapReboot(Server srv) { + _askFor( + func: () => srv.client?.execWithPwd( + ShellFunc.reboot.exec(srv.spi.id), + context: context, + id: srv.id, + ), + typ: l10n.reboot, + name: srv.spi.name, + ); + } + + void _onTapEdit(Server srv) { + if (srv.canViewDetails) { + _splitViewCtrl.replace(ServerDetailPage( + key: ValueKey(srv.spi.id), + args: SpiRequiredArgs(srv.spi), + )); + } else { + _splitViewCtrl.replace(ServerEditPage( + key: ValueKey(srv.spi.id), + args: SpiRequiredArgs(srv.spi), + )); + } + } +} + +extension _Utils on _ServerPageState { + List _filterServers(List order) { + final tag = _tag.value; + if (tag == TagSwitcher.kDefaultTag) return order; + return order.where((e) { + final tags = ServerProvider.pick(id: e)?.value.spi.tags; + if (tags == null) return false; + return tags.contains(tag); + }).toList(); + } + + double? _calcCardHeight(ServerConn cs, bool flip) { + if (_textFactorDouble != 1.0) return null; + if (cs != ServerConn.finished) { + return _ServerPageState._kCardHeightMin; + } + if (flip) { + return _ServerPageState._kCardHeightFlip; + } + if (Stores.setting.moveServerFuncs.fetch() && + // Discussion #146 + !Stores.setting.serverTabUseOldUI.fetch()) { + return _ServerPageState._kCardHeightMoveOutFuncs; + } + return _ServerPageState._kCardHeightNormal; + } + + void _askFor({ + required void Function() func, + required String typ, + required String name, + }) { + context.showRoundDialog( + title: libL10n.attention, + child: Text(libL10n.askContinue('$typ ${l10n.server}($name)')), + actions: Btn.ok( + onTap: () { + context.pop(); + func(); + }, + ).toList, + ); + } + + _CardNotifier _getCardNoti(String id) => _cardsStatus.putIfAbsent( + id, + () => _CardNotifier(const _CardStatus()), + ); + + void _updateOffset() { + if (!Stores.setting.fullScreenJitter.fetch()) return; + final x = _media.size.height * 0.03; + final r = math.Random().nextDouble(); + final n = math.Random().nextBool() ? 1 : -1; + _offset = x * r * n; + } + + void _updateTextScaler() { + _textFactorDouble = Stores.setting.textFactor.fetch(); + _textFactor = TextScaler.linear(_textFactorDouble); + } + + void _startAvoidJitterTimer() { + if (!Stores.setting.fullScreenJitter.fetch()) return; + _timer = Timer.periodic(const Duration(seconds: 30), (_) { + if (mounted) { + _updateOffset(); + setState(() {}); + } else { + _timer?.cancel(); + } + }); + } +} + +extension _ServerX on Server { + String? _getTopRightStr(Spi spi) { + switch (conn) { + case ServerConn.disconnected: + return null; + case ServerConn.finished: + // Highest priority of temperature display + final cmdTemp = () { + final val = status.customCmds['server_card_top_right']; + if (val == null) return null; + // This returned value is used on server card top right, so it should + // be a single line string. + return val.split('\n').lastOrNull; + }(); + final temperatureVal = () { + // Second priority + final preferTempDev = spi.custom?.preferTempDev; + if (preferTempDev != null) { + final preferTemp = status.sensors + .firstWhereOrNull((e) => e.device == preferTempDev) + ?.summary + ?.split(' ') + .firstOrNull; + if (preferTemp != null) { + return double.tryParse(preferTemp.replaceFirst('°C', '')); + } + } + // Last priority + final temp = status.temps.first; + if (temp != null) { + return temp; + } + return null; + }(); + final upTime = status.more[StatusCmdType.uptime]; + final items = [ + cmdTemp ?? (temperatureVal != null ? '${temperatureVal.toStringAsFixed(1)}°C' : null), + upTime + ]; + final str = items.where((e) => e != null && e.isNotEmpty).join(' | '); + if (str.isEmpty) return libL10n.empty; + return str; + case ServerConn.loading: + return null; + case ServerConn.connected: + return null; + case ServerConn.connecting: + return null; + case ServerConn.failed: + return status.err != null ? l10n.viewErr : libL10n.fail; + } + } +} diff --git a/lib/view/page/setting/entries/app.dart b/lib/view/page/setting/entries/app.dart index 9e53b065..3563e249 100644 --- a/lib/view/page/setting/entries/app.dart +++ b/lib/view/page/setting/entries/app.dart @@ -17,8 +17,8 @@ extension _App on _AppSettingsPageState { Widget? _buildPlatformSetting() { final func = switch (Pfs.type) { - Pfs.android => AppRoutes.androidSettings().go, - Pfs.ios => AppRoutes.iosSettings().go, + Pfs.android => AndroidSettingsPage.route.go, + Pfs.ios => IosSettingsPage.route.go, _ => null, }; if (func == null) return null; diff --git a/lib/view/page/setting/entries/server.dart b/lib/view/page/setting/entries/server.dart index 7559b7e5..cbd99ee4 100644 --- a/lib/view/page/setting/entries/server.dart +++ b/lib/view/page/setting/entries/server.dart @@ -142,7 +142,7 @@ extension _Server on _AppSettingsPageState { leading: const Icon(OctIcons.sort_desc, size: _kIconSize), title: Text(l10n.serverOrder), trailing: const Icon(Icons.keyboard_arrow_right), - onTap: () => AppRoutes.serverOrder().go(context), + onTap: () => ServerOrderPage.route.go(context), ); } @@ -151,7 +151,7 @@ extension _Server on _AppSettingsPageState { leading: const Icon(OctIcons.sort_desc, size: _kIconSize), title: Text(l10n.serverDetailOrder), trailing: const Icon(Icons.keyboard_arrow_right), - onTap: () => AppRoutes.serverDetailOrder().go(context), + onTap: () => ServerDetailOrderPage.route.go(context), ); } diff --git a/lib/view/page/setting/entries/ssh.dart b/lib/view/page/setting/entries/ssh.dart index 4c8c8752..01662657 100644 --- a/lib/view/page/setting/entries/ssh.dart +++ b/lib/view/page/setting/entries/ssh.dart @@ -20,7 +20,7 @@ extension _SSH on _AppSettingsPageState { leading: const Icon(BoxIcons.bxs_keyboard), title: Text(l10n.editVirtKeys), trailing: const Icon(Icons.keyboard_arrow_right), - onTap: () => AppRoutes.sshVirtKeySetting().go(context), + onTap: () => SSHVirtKeySettingPage.route.go(context), ); } diff --git a/lib/view/page/setting/entry.dart b/lib/view/page/setting/entry.dart index 4fd407e3..7fa1b3bb 100644 --- a/lib/view/page/setting/entry.dart +++ b/lib/view/page/setting/entry.dart @@ -13,13 +13,17 @@ import 'package:server_box/data/res/github_id.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/url.dart'; -import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/app/net_view.dart'; import 'package:server_box/data/res/build_data.dart'; import 'package:server_box/view/page/backup.dart'; import 'package:server_box/view/page/editor.dart'; import 'package:server_box/view/page/private_key/list.dart'; +import 'package:server_box/view/page/setting/platform/android.dart'; +import 'package:server_box/view/page/setting/platform/ios.dart'; +import 'package:server_box/view/page/setting/seq/srv_detail_seq.dart'; import 'package:server_box/view/page/setting/seq/srv_func_seq.dart'; +import 'package:server_box/view/page/setting/seq/srv_seq.dart'; +import 'package:server_box/view/page/setting/seq/virt_key.dart'; part 'about.dart'; part 'entries/app.dart'; @@ -35,16 +39,17 @@ const _kIconSize = 23.0; class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); - static const route = AppRouteNoArg(page: SettingsPage.new, path: '/settings'); + static const route = AppRouteNoArg( + page: SettingsPage.new, + path: '/settings', + ); @override State createState() => _SettingsPageState(); } -class _SettingsPageState extends State - with SingleTickerProviderStateMixin { - late final _tabCtrl = - TabController(length: SettingsTabs.values.length, vsync: this); +class _SettingsPageState extends State with SingleTickerProviderStateMixin { + late final _tabCtrl = TabController(length: SettingsTabs.values.length, vsync: this); @override void dispose() { @@ -62,9 +67,7 @@ class _SettingsPageState extends State dividerHeight: 0, tabAlignment: TabAlignment.center, isScrollable: true, - tabs: SettingsTabs.values - .map((e) => Tab(text: e.i18n)) - .toList(growable: false), + tabs: SettingsTabs.values.map((e) => Tab(text: e.i18n)).toList(growable: false), ), actions: [ Btn.text( @@ -120,12 +123,7 @@ final class _AppSettingsPageState extends State { children: [ [const CenterGreyTitle('App'), _buildApp()], [CenterGreyTitle(l10n.server), _buildServer()], - [ - const CenterGreyTitle('SSH'), - _buildSSH(), - const CenterGreyTitle('SFTP'), - _buildSFTP() - ], + [const CenterGreyTitle('SSH'), _buildSSH(), const CenterGreyTitle('SFTP'), _buildSFTP()], [ CenterGreyTitle(l10n.container), _buildContainer(), @@ -162,6 +160,5 @@ enum SettingsTabs { SettingsTabs.about => const _AppAboutPage(), }; - static final List pages = - SettingsTabs.values.map((e) => e.page).toList(); + static final List pages = SettingsTabs.values.map((e) => e.page).toList(); } diff --git a/lib/view/page/setting/platform/android.dart b/lib/view/page/setting/platform/android.dart index ac14532a..0c5f3c55 100644 --- a/lib/view/page/setting/platform/android.dart +++ b/lib/view/page/setting/platform/android.dart @@ -9,6 +9,11 @@ class AndroidSettingsPage extends StatefulWidget { @override State createState() => _AndroidSettingsPageState(); + + static const route = AppRouteNoArg( + page: AndroidSettingsPage.new, + path: '/settings/android', + ); } const _homeWidgetPrefPrefix = 'widget_'; @@ -24,8 +29,7 @@ class _AndroidSettingsPageState extends State { // _buildFgService(), _buildBgRun(), _buildAndroidWidgetSharedPreference(), - if (BioAuth.isPlatformSupported) - PlatformPublicSettings.buildBioAuth(), + if (BioAuth.isPlatformSupported) PlatformPublicSettings.buildBioAuth(), ].map((e) => CardX(child: e)).toList(), ), ); diff --git a/lib/view/page/setting/platform/ios.dart b/lib/view/page/setting/platform/ios.dart index 4f0eb165..ecb5dae4 100644 --- a/lib/view/page/setting/platform/ios.dart +++ b/lib/view/page/setting/platform/ios.dart @@ -6,14 +6,19 @@ import 'package:server_box/data/res/store.dart'; import 'package:server_box/view/page/setting/platform/platform_pub.dart'; import 'package:watch_connectivity/watch_connectivity.dart'; -class IOSSettingsPage extends StatefulWidget { - const IOSSettingsPage({super.key}); +class IosSettingsPage extends StatefulWidget { + const IosSettingsPage({super.key}); @override - State createState() => _IOSSettingsPageState(); + State createState() => _IosSettingsPageState(); + + static const route = AppRouteNoArg( + page: IosSettingsPage.new, + path: '/settings/ios', + ); } -class _IOSSettingsPageState extends State { +class _IosSettingsPageState extends State { final _pushToken = ValueNotifier(null); final wc = WatchConnectivity(); diff --git a/lib/view/page/setting/seq/srv_detail_seq.dart b/lib/view/page/setting/seq/srv_detail_seq.dart index ccf6df8e..f02a3587 100644 --- a/lib/view/page/setting/seq/srv_detail_seq.dart +++ b/lib/view/page/setting/seq/srv_detail_seq.dart @@ -9,6 +9,11 @@ class ServerDetailOrderPage extends StatefulWidget { @override State createState() => _ServerDetailOrderPageState(); + + static const route = AppRouteNoArg( + page: ServerDetailOrderPage.new, + path: '/settings/order/server_detail', + ); } class _ServerDetailOrderPageState extends State { diff --git a/lib/view/page/setting/seq/srv_func_seq.dart b/lib/view/page/setting/seq/srv_func_seq.dart index afabf2ca..a50c52e5 100644 --- a/lib/view/page/setting/seq/srv_func_seq.dart +++ b/lib/view/page/setting/seq/srv_func_seq.dart @@ -10,7 +10,10 @@ class ServerFuncBtnsOrderPage extends StatefulWidget { @override State createState() => _ServerDetailOrderPageState(); - static const route = AppRouteNoArg(page: ServerFuncBtnsOrderPage.new, path: '/setting/seq/srv_func'); + static const route = AppRouteNoArg( + page: ServerFuncBtnsOrderPage.new, + path: '/setting/seq/srv_func', + ); } class _ServerDetailOrderPageState extends State { @@ -28,10 +31,7 @@ class _ServerDetailOrderPageState extends State { return ValBuilder( listenable: prop.listenable(), builder: (keys) { - final disabled = ServerFuncBtn.values - .map((e) => e.index) - .where((e) => !keys.contains(e)) - .toList(); + final disabled = ServerFuncBtn.values.map((e) => e.index).where((e) => !keys.contains(e)).toList(); final allKeys = [...keys, ...disabled]; return ReorderableListView.builder( padding: const EdgeInsets.all(7), diff --git a/lib/view/page/setting/seq/srv_seq.dart b/lib/view/page/setting/seq/srv_seq.dart index d74970ba..d4f6f040 100644 --- a/lib/view/page/setting/seq/srv_seq.dart +++ b/lib/view/page/setting/seq/srv_seq.dart @@ -10,6 +10,11 @@ class ServerOrderPage extends StatefulWidget { @override State createState() => _ServerOrderPageState(); + + static const route = AppRouteNoArg( + page: ServerOrderPage.new, + path: '/settings/order/server', + ); } class _ServerOrderPageState extends State { diff --git a/lib/view/page/setting/seq/virt_key.dart b/lib/view/page/setting/seq/virt_key.dart index 30d2a1d9..966de777 100644 --- a/lib/view/page/setting/seq/virt_key.dart +++ b/lib/view/page/setting/seq/virt_key.dart @@ -9,6 +9,11 @@ class SSHVirtKeySettingPage extends StatefulWidget { @override State createState() => _SSHVirtKeySettingPageState(); + + static const route = AppRouteNoArg( + page: SSHVirtKeySettingPage.new, + path: '/settings/ssh_virt_key', + ); } class _SSHVirtKeySettingPageState extends State { diff --git a/lib/view/page/snippet/edit.dart b/lib/view/page/snippet/edit.dart index c48ca410..c1ffe84c 100644 --- a/lib/view/page/snippet/edit.dart +++ b/lib/view/page/snippet/edit.dart @@ -6,17 +6,26 @@ import 'package:server_box/data/model/server/snippet.dart'; import 'package:server_box/data/provider/server.dart'; import 'package:server_box/data/provider/snippet.dart'; -class SnippetEditPage extends StatefulWidget { - const SnippetEditPage({super.key, this.snippet}); - +final class SnippetEditPageArgs { final Snippet? snippet; + const SnippetEditPageArgs({this.snippet}); +} + +class SnippetEditPage extends StatefulWidget { + final SnippetEditPageArgs? args; + + const SnippetEditPage({super.key, this.args}); @override State createState() => _SnippetEditPageState(); + + static const route = AppRoute( + page: SnippetEditPage.new, + path: '/snippets/edit', + ); } -class _SnippetEditPageState extends State - with AfterLayoutMixin { +class _SnippetEditPageState extends State with AfterLayoutMixin { final _nameController = TextEditingController(); final _scriptController = TextEditingController(); final _noteController = TextEditingController(); @@ -48,18 +57,19 @@ class _SnippetEditPageState extends State } List? _buildAppBarActions() { - if (widget.snippet == null) return null; + final snippet = widget.args?.snippet; + if (snippet == null) return null; return [ IconButton( onPressed: () { context.showRoundDialog( title: libL10n.attention, child: Text(libL10n.askContinue( - '${libL10n.delete} ${l10n.snippet}(${widget.snippet!.name})', + '${libL10n.delete} ${l10n.snippet}(${snippet.name})', )), actions: Btn.ok( onTap: () { - SnippetProvider.del(widget.snippet!); + SnippetProvider.del(snippet); context.pop(); context.pop(); }, @@ -92,8 +102,9 @@ class _SnippetEditPageState extends State note: note.isEmpty ? null : note, autoRunOn: _autoRunOn.value.isEmpty ? null : _autoRunOn.value, ); - if (widget.snippet != null) { - SnippetProvider.update(widget.snippet!, snippet); + final oldSnippet = widget.args?.snippet; + if (oldSnippet != null) { + SnippetProvider.update(oldSnippet, snippet); } else { SnippetProvider.add(snippet); } @@ -103,8 +114,7 @@ class _SnippetEditPageState extends State } Widget _buildBody() { - return ListView( - padding: const EdgeInsets.symmetric(horizontal: 13), + return AutoMultiList( children: [ Input( autoFocus: true, @@ -148,9 +158,7 @@ class _SnippetEditPageState extends State builder: (vals) { final subtitle = vals.isEmpty ? null - : vals - .map((e) => ServerProvider.pick(id: e)?.value.spi.name ?? e) - .join(', '); + : vals.map((e) => ServerProvider.pick(id: e)?.value.spi.name ?? e).join(', '); return ListTile( leading: const Padding( padding: EdgeInsets.only(left: 5), @@ -167,8 +175,7 @@ class _SnippetEditPageState extends State overflow: TextOverflow.ellipsis, ), onTap: () async { - vals.removeWhere( - (e) => !ServerProvider.serverOrder.value.contains(e)); + vals.removeWhere((e) => !ServerProvider.serverOrder.value.contains(e)); final serverIds = await context.showPickDialog( title: l10n.autoRun, items: ServerProvider.serverOrder.value, @@ -193,9 +200,9 @@ class _SnippetEditPageState extends State child: SimpleMarkdown( data: ''' 📌 ${l10n.supportFmtArgs}\n -${Snippet.fmtArgs.keys.map((e) => '`$e`').join(', ')}\n +${SnippetX.fmtArgs.keys.map((e) => '`$e`').join(', ')}\n -${Snippet.fmtTermKeys.keys.map((e) => '`$e+?}`').join(', ')}\n +${SnippetX.fmtTermKeys.keys.map((e) => '`$e+?}`').join(', ')}\n ${libL10n.example}: - `\${ctrl+c}` (Control + C) - `\${ctrl+b}d` (Tmux Detach) @@ -212,7 +219,7 @@ ${libL10n.example}: @override void afterFirstLayout(BuildContext context) { - final snippet = widget.snippet; + final snippet = widget.args?.snippet; if (snippet != null) { _nameController.text = snippet.name; _scriptController.text = snippet.script; diff --git a/lib/view/page/snippet/list.dart b/lib/view/page/snippet/list.dart index b0157f54..8796b869 100644 --- a/lib/view/page/snippet/list.dart +++ b/lib/view/page/snippet/list.dart @@ -1,123 +1,164 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:server_box/data/res/store.dart'; +import 'package:flutter_reorderable_grid_view/widgets/widgets.dart'; import 'package:server_box/data/model/server/snippet.dart'; -import 'package:server_box/core/route.dart'; import 'package:server_box/data/provider/snippet.dart'; +import 'package:server_box/view/page/snippet/edit.dart'; class SnippetListPage extends StatefulWidget { const SnippetListPage({super.key}); @override State createState() => _SnippetListPageState(); + + static const route = AppRouteNoArg( + page: SnippetListPage.new, + path: '/snippets', + ); } -class _SnippetListPageState extends State - with AutomaticKeepAliveClientMixin { +class _SnippetListPageState extends State with AutomaticKeepAliveClientMixin { final _tag = ''.vn; + final _splitViewCtrl = SplitViewController(); @override void dispose() { super.dispose(); _tag.dispose(); + _splitViewCtrl.dispose(); } @override Widget build(BuildContext context) { super.build(context); + return _buildBody(); + } + + Widget _buildBody() { + // final isMobile = ResponsiveBreakpoints.of(context).isMobile; + return SnippetProvider.snippets.listenVal( + (snippets) { + return _tag.listenVal((tag) { + final child = _buildScaffold(snippets, tag); + // if (isMobile) { + return child; + // } + + // return SplitView( + // controller: _splitViewCtrl, + // leftWeight: 1, + // rightWeight: 1.3, + // initialRight: Center(child: Text(libL10n.empty)), + // leftBuilder: (_, __) => child, + // ); + }); + }, + ); + } + + Widget _buildScaffold(List snippets, String tag) { return Scaffold( appBar: TagSwitcher( tags: SnippetProvider.tags, onTagChanged: (tag) => _tag.value = tag, initTag: _tag.value, ), - body: _buildBody(), + body: _buildSnippetList(snippets, tag), floatingActionButton: FloatingActionButton( heroTag: 'snippetAdd', child: const Icon(Icons.add), - onPressed: () => AppRoutes.snippetEdit().go(context), + onPressed: () { + // if (ResponsiveBreakpoints.of(context).isMobile) { + SnippetEditPage.route.go(context); + // } else { + // _splitViewCtrl.replace(const SnippetEditPage()); + // } + }, ), ); } - Widget _buildBody() { - return SnippetProvider.snippets.listenVal( - (snippets) { - if (snippets.isEmpty) return Center(child: Text(libL10n.empty)); - return _tag.listenVal((tag) => _buildSnippetList(snippets, tag)); - }, - ); - } - Widget _buildSnippetList(List snippets, String tag) { + if (snippets.isEmpty) return Center(child: Text(libL10n.empty)); + final filtered = tag == TagSwitcher.kDefaultTag ? snippets : snippets.where((e) => e.tags?.contains(tag) ?? false).toList(); - return ReorderableListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 9), - itemCount: filtered.length, - onReorder: (oldIdx, newIdx) { - snippets.moveByItem( - oldIdx, - newIdx, - filtered: filtered, - onMove: (p0) { - Stores.setting.snippetOrder.put(p0.map((e) => e.name).toList()); - }, - ); - SnippetProvider.snippets.notify(); - }, - footer: UIs.height77, - buildDefaultDragHandles: false, - itemBuilder: (context, idx) { - final snippet = filtered.elementAt(idx); - return ReorderableDelayedDragStartListener( - key: ValueKey(idx), - index: idx, + final generatedChildren = List.generate( + filtered.length, + (idx) { + final snippet = filtered.elementAtOrNull(idx); + if (snippet == null) return UIs.placeholder; + return Container( + key: ValueKey(snippet.name), child: _buildSnippetItem(snippet), ); }, ); + + return ReorderableBuilder( + children: generatedChildren, + onReorder: (ReorderedListFunction reorderedListFunction) { + setState(() { + final newFiltered = reorderedListFunction(filtered) as List; + snippets.moveByItem( + 0, + 0, + filtered: filtered, + onMove: (p0) { + Stores.setting.snippetOrder.put(newFiltered.map((e) => e.name).toList()); + }, + ); + SnippetProvider.snippets.notify(); + }); + }, + builder: (children) { + return GridView( + padding: const EdgeInsets.symmetric(horizontal: 9), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 330, + childAspectRatio: 3.4, + ), + children: children, + ); + }, + ); } Widget _buildSnippetItem(Snippet snippet) { - return CardX( - child: ListTile( - contentPadding: const EdgeInsets.only(left: 23, right: 17), - title: Text( - snippet.name, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - subtitle: Text( - snippet.note ?? snippet.script, - overflow: TextOverflow.ellipsis, - maxLines: 3, - style: UIs.textGrey, - ), - trailing: const Icon(Icons.keyboard_arrow_right), - onTap: () => AppRoutes.snippetEdit(snippet: snippet).go(context), + return ListTile( + contentPadding: const EdgeInsets.only(left: 23, right: 17), + title: Text( + snippet.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, ), - ); + subtitle: Text( + snippet.note ?? snippet.script, + overflow: TextOverflow.ellipsis, + maxLines: 3, + style: UIs.textGrey, + ), + trailing: const Icon(Icons.keyboard_arrow_right), + onTap: () { + // final isMobile = ResponsiveBreakpoints.of(context).isMobile; + // if (isMobile) { + SnippetEditPage.route.go( + context, + args: SnippetEditPageArgs(snippet: snippet), + ); + // } else { + // _splitViewCtrl.replace(SnippetEditPage( + // args: SnippetEditPageArgs(snippet: snippet), + // )); + // } + }, + ).cardx; } @override bool get wantKeepAlive => true; - - // Future _runSnippet(Snippet snippet) async { - // final servers = await context.showPickDialog( - // items: Pros.server.servers.toList(), - // name: (e) => e.spi.name, - // ); - // if (servers == null) { - // return; - // } - // final ids = servers.map((e) => e.spi.id).toList(); - // final results = await Pros.server.runSnippetsMulti(ids, snippet); - // if (results.isNotEmpty) { - // AppRoutes.snippetResult(results: results).go(context); - // } - // } } diff --git a/lib/view/page/snippet/result.dart b/lib/view/page/snippet/result.dart index bc8f606c..89110d65 100644 --- a/lib/view/page/snippet/result.dart +++ b/lib/view/page/snippet/result.dart @@ -4,9 +4,14 @@ import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/data/model/server/snippet.dart'; class SnippetResultPage extends StatelessWidget { - final List results; + final List args; - const SnippetResultPage({super.key, required this.results}); + const SnippetResultPage({super.key, required this.args}); + + static const route = AppRouteArg( + page: SnippetResultPage.new, + path: '/snippets/result', + ); @override Widget build(BuildContext context) { @@ -19,13 +24,13 @@ class SnippetResultPage extends StatelessWidget { Widget _buildBody() { return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 17), - itemCount: results.length, + itemCount: args.length, itemBuilder: (_, index) { - final item = results[index]; + final item = args[index]; if (item == null) return UIs.placeholder; return CardX( child: ExpandTile( - initiallyExpanded: results.length == 1, + initiallyExpanded: args.length == 1, title: Text(item.dest ?? ''), subtitle: Text(item.time.toString(), style: UIs.textGrey), children: [ diff --git a/lib/view/page/ssh/page.dart b/lib/view/page/ssh/page.dart index fcbce5d2..2389d85f 100644 --- a/lib/view/page/ssh/page.dart +++ b/lib/view/page/ssh/page.dart @@ -14,18 +14,18 @@ import 'package:server_box/data/model/server/snippet.dart'; import 'package:server_box/data/provider/snippet.dart'; import 'package:server_box/data/provider/virtual_keyboard.dart'; import 'package:server_box/data/res/store.dart'; +import 'package:server_box/view/page/storage/sftp.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:xterm/core.dart'; import 'package:xterm/ui.dart' hide TerminalThemes; -import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/ssh/virtual_key.dart'; import 'package:server_box/data/res/terminal.dart'; const _echoPWD = 'echo \$PWD'; -class SSHPage extends StatefulWidget { +final class SshPageArgs { final Spi spi; final String? initCmd; final Snippet? initSnippet; @@ -34,8 +34,7 @@ class SSHPage extends StatefulWidget { final GlobalKey? terminalKey; final FocusNode? focusNode; - const SSHPage({ - super.key, + const SshPageArgs({ required this.spi, this.initCmd, this.initSnippet, @@ -44,20 +43,33 @@ class SSHPage extends StatefulWidget { this.terminalKey, this.focusNode, }); +} + +class SSHPage extends StatefulWidget { + final SshPageArgs args; + + const SSHPage({ + super.key, + required this.args, + }); @override State createState() => SSHPageState(); + + static const route = AppRouteArg( + page: SSHPage.new, + path: '/ssh/page', + ); } const _horizonPadding = 7.0; -class SSHPageState extends State - with AutomaticKeepAliveClientMixin, AfterLayoutMixin { +class SSHPageState extends State with AutomaticKeepAliveClientMixin, AfterLayoutMixin { final _keyboard = VirtKeyProvider(); late final _terminal = Terminal(inputHandler: _keyboard); final TerminalController _terminalController = TerminalController(); final List> _virtKeysList = []; - late final _termKey = widget.terminalKey ?? GlobalKey(); + late final _termKey = widget.args.terminalKey ?? GlobalKey(); late MediaQueryData _media; late TerminalStyle _terminalStyle; @@ -68,7 +80,7 @@ class SSHPageState extends State bool _isDark = false; Timer? _virtKeyLongPressTimer; - late SSHClient? _client = widget.spi.server?.value.client; + late SSHClient? _client = widget.args.spi.server?.value.client; Timer? _discontinuityTimer; /// Used for (de)activate the wake lock and forground service @@ -148,13 +160,10 @@ class SSHPageState extends State Widget _buildBody() { final letterCache = Stores.setting.letterCache.fetch(); return SizedBox( - height: _media.size.height - - _virtKeysHeight - - _media.padding.bottom - - _media.padding.top, + height: _media.size.height - _virtKeysHeight - _media.padding.bottom - _media.padding.top, child: Padding( padding: EdgeInsets.only( - top: widget.notFromTab ? CustomAppBar.sysStatusBarHeight : 0, + top: widget.args.notFromTab ? CustomAppBar.sysStatusBarHeight : 0, left: _horizonPadding, right: _horizonPadding, ), @@ -175,7 +184,7 @@ class SSHPageState extends State CustomAppBar.sysStatusBarHeight, ), hideScrollBar: false, - focusNode: widget.focusNode, + focusNode: widget.args.focusNode, ), ), ); @@ -192,8 +201,7 @@ class SSHPageState extends State height: _virtKeysHeight, child: ChangeNotifierProvider( create: (_) => _keyboard, - builder: (_, __) => - Consumer(builder: (_, __, ___) { + builder: (_, __) => Consumer(builder: (_, __, ___) { return _buildVirtualKey(); }), ), @@ -207,14 +215,11 @@ class SSHPageState extends State return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - children: - _virtKeysList.expand((e) => e).map(_buildVirtKeyItem).toList(), + children: _virtKeysList.expand((e) => e).map(_buildVirtKeyItem).toList(), ), ); } - final rows = _virtKeysList - .map((e) => Row(children: e.map(_buildVirtKeyItem).toList())) - .toList(); + final rows = _virtKeysList.map((e) => Row(children: e.map(_buildVirtKeyItem).toList())).toList(); return Column( mainAxisSize: MainAxisSize.min, children: rows, @@ -243,9 +248,7 @@ class SSHPageState extends State : Text( item.text, style: TextStyle( - color: selected - ? UIs.primaryColor - : (_isDark ? Colors.white : Colors.black), + color: selected ? UIs.primaryColor : (_isDark ? Colors.white : Colors.black), fontSize: 15, ), ); @@ -315,7 +318,7 @@ extension _Init on SSHPageState { Future _initTerminal() async { _writeLn(l10n.waitConnection); _client ??= await genClient( - widget.spi, + widget.args.spi, onStatus: (p0) { _writeLn(p0.toString()); }, @@ -328,7 +331,7 @@ extension _Init on SSHPageState { width: _terminal.viewWidth, height: _terminal.viewHeight, ), - environment: widget.spi.envs, + environment: widget.args.spi.envs, ); //_setupDiscontinuityTimer(); @@ -352,37 +355,34 @@ extension _Init on SSHPageState { _listen(session.stderr); for (final snippet in SnippetProvider.snippets.value) { - if (snippet.autoRunOn?.contains(widget.spi.id) == true) { - snippet.runInTerm(_terminal, widget.spi); + if (snippet.autoRunOn?.contains(widget.args.spi.id) == true) { + snippet.runInTerm(_terminal, widget.args.spi); } } - if (widget.initCmd != null) { - _terminal.textInput(widget.initCmd!); + if (widget.args.initCmd != null) { + _terminal.textInput(widget.args.initCmd!); _terminal.keyInput(TerminalKey.enter); } - if (widget.initSnippet != null) { - widget.initSnippet!.runInTerm(_terminal, widget.spi); + if (widget.args.initSnippet != null) { + widget.args.initSnippet!.runInTerm(_terminal, widget.args.spi); } - widget.focusNode?.requestFocus(); + widget.args.focusNode?.requestFocus(); await session.done; - if (mounted && widget.notFromTab) { + if (mounted && widget.args.notFromTab) { context.pop(); } - widget.onSessionEnd?.call(); + widget.args.onSessionEnd?.call(); } void _listen(Stream? stream) { if (stream == null) { return; } - stream - .cast>() - .transform(const Utf8Decoder()) - .listen(_terminal.write); + stream.cast>().transform(const Utf8Decoder()).listen(_terminal.write); } void _setupDiscontinuityTimer() { @@ -490,7 +490,7 @@ extension _VirtKey on SSHPageState { final snippet = snippets.firstOrNull; if (snippet == null) return; - snippet.runInTerm(_terminal, widget.spi); + snippet.runInTerm(_terminal, widget.args.spi); break; case VirtualKeyFunc.file: // get $PWD from SSH session @@ -509,7 +509,8 @@ extension _VirtKey on SSHPageState { ); return; } - AppRoutes.sftp(spi: widget.spi, initPath: initPath).go(context); + final args = SftpPageArgs(spi: widget.args.spi, initPath: initPath); + SftpPage.route.go(context, args); } } @@ -547,6 +548,6 @@ extension _VirtKey on SSHPageState { } FutureOr?> _onKeyboardInteractive(SSHUserInfoRequest req) { - return KeybordInteractive.defaultHandle(widget.spi, ctx: context); + return KeybordInteractive.defaultHandle(widget.args.spi, ctx: context); } } diff --git a/lib/view/page/ssh/tab.dart b/lib/view/page/ssh/tab.dart index 98b92441..8aead183 100644 --- a/lib/view/page/ssh/tab.dart +++ b/lib/view/page/ssh/tab.dart @@ -15,6 +15,11 @@ class SSHTabPage extends StatefulWidget { @override State createState() => _SSHTabPageState(); + + static const route = AppRouteNoArg( + page: SSHTabPage.new, + path: '/ssh', + ); } typedef _TabMap = Map; @@ -93,10 +98,7 @@ extension on _SSHTabPageState { void _onTapInitCard(Spi spi) async { final name = () { final reg = RegExp('${spi.name}\\((\\d+)\\)'); - final idxs = _tabMap.keys - .map((e) => reg.firstMatch(e)) - .map((e) => e?.group(1)) - .whereType(); + final idxs = _tabMap.keys.map((e) => reg.firstMatch(e)).map((e) => e?.group(1)).whereType(); if (idxs.isEmpty) { return _tabMap.keys.contains(spi.name) ? '${spi.name}(1)' : spi.name; } @@ -108,15 +110,17 @@ extension on _SSHTabPageState { return spi.name; }(); final key = Key(name); + final args = SshPageArgs( + spi: spi, + notFromTab: false, + onSessionEnd: () { + _tabMap.remove(name); + }, + ); _tabMap[name] = ( page: SSHPage( - // Keep it, or the Flutter will works unexpectedly - key: key, - spi: spi, - notFromTab: false, - onSessionEnd: () { - _tabMap.remove(name); - }, + key: key, // Keep it, or the Flutter will works unexpectedly + args: args, ), focus: FocusNode(), ); @@ -128,8 +132,7 @@ extension on _SSHTabPageState { } Future _toPage(int idx) async { - await _pageCtrl.animateToPage(idx, - duration: Durations.short3, curve: Curves.fastEaseInToSlowEaseOut); + await _pageCtrl.animateToPage(idx, duration: Durations.short3, curve: Curves.fastEaseInToSlowEaseOut); final focus = _tabMap.values.elementAt(idx).focus; if (focus != null) { FocusScope.of(context).requestFocus(focus); @@ -156,8 +159,7 @@ extension on _SSHTabPageState { _tabMap.remove(name); _tabRN.notify(); - _pageCtrl.previousPage( - duration: Durations.medium1, curve: Curves.fastEaseInToSlowEaseOut); + _pageCtrl.previousPage(duration: Durations.medium1, curve: Curves.fastEaseInToSlowEaseOut); } } @@ -278,8 +280,7 @@ class _AddPage extends StatelessWidget { const itemHeight = 50.0; final visualCrossCount = viewWidth / itemWidth; - final crossCount = - max(viewWidth ~/ (visualCrossCount * itemPadding + itemWidth), 1); + final crossCount = max(viewWidth ~/ (visualCrossCount * itemPadding + itemWidth), 1); final mainCount = itemCount ~/ crossCount + 1; return ServerProvider.serverOrder.listenVal((order) { diff --git a/lib/view/page/storage/local.dart b/lib/view/page/storage/local.dart index 6410a8fa..b8d138c6 100644 --- a/lib/view/page/storage/local.dart +++ b/lib/view/page/storage/local.dart @@ -9,9 +9,10 @@ import 'package:server_box/data/provider/server.dart'; import 'package:server_box/data/provider/sftp.dart'; import 'package:server_box/data/res/misc.dart'; -import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/app/path_with_prefix.dart'; import 'package:server_box/view/page/editor.dart'; +import 'package:server_box/view/page/storage/sftp.dart'; +import 'package:server_box/view/page/storage/sftp_mission.dart'; final class LocalFilePageArgs { final bool? isPickFile; @@ -29,15 +30,14 @@ class LocalFilePage extends StatefulWidget { static const route = AppRoute( page: LocalFilePage.new, - path: '/local_file', + path: '/files/local', ); @override State createState() => _LocalFilePageState(); } -class _LocalFilePageState extends State - with AutomaticKeepAliveClientMixin { +class _LocalFilePageState extends State with AutomaticKeepAliveClientMixin { late final _path = LocalPath(widget.args?.initDir ?? Paths.file); final _sortType = _SortType.name.vn; bool get isPickFile => widget.args?.isPickFile ?? false; @@ -125,13 +125,9 @@ class _LocalFilePageState extends State return CardX( child: ListTile( - leading: isDir - ? const Icon(Icons.folder_open) - : const Icon(Icons.insert_drive_file), + leading: isDir ? const Icon(Icons.folder_open) : const Icon(Icons.insert_drive_file), title: Text(fileName), - subtitle: isDir - ? null - : Text(stat.size.bytes2Str, style: UIs.textGrey), + subtitle: isDir ? null : Text(stat.size.bytes2Str, style: UIs.textGrey), trailing: Text( stat.modified.ymdhms(), style: UIs.textGrey, @@ -262,10 +258,11 @@ class _LocalFilePageState extends State ); if (spi == null) return; - final remotePath = await AppRoutes.sftp( + final args = SftpPageArgs( spi: spi, isSelect: true, - ).go(context); + ); + final remotePath = await SftpPage.route.go(context, args); if (remotePath == null) { return; } @@ -346,7 +343,7 @@ class _LocalFilePageState extends State Widget _buildMissionBtn() { return IconButton( icon: const Icon(Icons.downloading), - onPressed: () => AppRoutes.sftpMission().go(context), + onPressed: () => SftpMissionPage.route.go(context), ); } @@ -383,8 +380,7 @@ enum _SortType { files.sort((a, b) => a.statSync().size.compareTo(b.statSync().size)); break; case _SortType.time: - files.sort( - (a, b) => a.statSync().modified.compareTo(b.statSync().modified)); + files.sort((a, b) => a.statSync().modified.compareTo(b.statSync().modified)); break; } return files; diff --git a/lib/view/page/storage/sftp.dart b/lib/view/page/storage/sftp.dart index b570196b..f8859fa0 100644 --- a/lib/view/page/storage/sftp.dart +++ b/lib/view/page/storage/sftp.dart @@ -6,7 +6,6 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/extension/sftpfile.dart'; -import 'package:server_box/core/route.dart'; import 'package:server_box/core/utils/comparator.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/sftp/browser_status.dart'; @@ -15,32 +14,48 @@ import 'package:server_box/data/provider/sftp.dart'; import 'package:server_box/data/res/misc.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/view/page/editor.dart'; +import 'package:server_box/view/page/ssh/page.dart'; import 'package:server_box/view/page/storage/local.dart'; +import 'package:server_box/view/page/storage/sftp_mission.dart'; import 'package:server_box/view/widget/omit_start_text.dart'; import 'package:server_box/view/widget/two_line_text.dart'; import 'package:server_box/view/widget/unix_perm.dart'; import 'package:icons_plus/icons_plus.dart'; -class SftpPage extends StatefulWidget { + +final class SftpPageArgs { final Spi spi; - final String? initPath; final bool isSelect; + final String? initPath; + + const SftpPageArgs({ + required this.spi, + this.isSelect = false, + this.initPath, + }); +} + +class SftpPage extends StatefulWidget { + final SftpPageArgs args; const SftpPage({ super.key, - required this.spi, - required this.isSelect, - this.initPath, + required this.args, }); @override State createState() => _SftpPageState(); + + static const route = AppRouteArg( + page: SftpPage.new, + path: '/sftp', + ); } class _SftpPageState extends State with AfterLayoutMixin { late final _status = SftpBrowserStatus(_client); - late final _client = widget.spi.server!.value.client!; + late final _client = widget.args.spi.server!.value.client!; final _sortOption = _SortOption().vn; @override @@ -54,7 +69,7 @@ class _SftpPageState extends State with AfterLayoutMixin { final children = [ Btn.icon( icon: const Icon(Icons.downloading), - onTap: () => AppRoutes.sftpMission().go(context), + onTap: () => SftpMissionPage.route.go(context), ), _buildSortMenu(), _buildSearchBtn(), @@ -63,7 +78,7 @@ class _SftpPageState extends State with AfterLayoutMixin { return Scaffold( appBar: CustomAppBar( - title: TwoLineText(up: 'SFTP', down: widget.spi.name), + title: TwoLineText(up: 'SFTP', down: widget.args.spi.name), actions: children, ), body: _buildFileView(), @@ -75,13 +90,13 @@ class _SftpPageState extends State with AfterLayoutMixin { FutureOr afterFirstLayout(BuildContext context) { var initPath = '/'; if (Stores.setting.sftpOpenLastPath.fetch()) { - final history = Stores.history.sftpLastPath.fetch(widget.spi.id); + final history = Stores.history.sftpLastPath.fetch(widget.args.spi.id); if (history != null) { initPath = history; } } - _status.path.path = widget.initPath ?? initPath; + _status.path.path = widget.args.initPath ?? initPath; _listDir(); } } @@ -93,9 +108,7 @@ extension _UI on _SftpPageState { (_SortType.size, l10n.size), (_SortType.time, l10n.time), ]; - return ValBuilder( - listenable: _sortOption, - builder: (value) { + return _sortOption.listenVal((value) { return PopupMenuButton<_SortType>( icon: const Icon(Icons.sort), itemBuilder: (context) { @@ -130,7 +143,7 @@ extension _UI on _SftpPageState { } Widget _buildBottom() { - final children = widget.isSelect + final children = widget.args.isSelect ? [ IconButton( onPressed: () => context.pop(_status.path.path), @@ -168,7 +181,7 @@ extension _UI on _SftpPageState { return RefreshIndicator( onRefresh: _listDir, child: FadeIn( - key: Key(widget.spi.name + _status.path.path), + key: Key(widget.args.spi.name + _status.path.path), child: ValBuilder( listenable: _sortOption, builder: (sortOption) { @@ -305,7 +318,8 @@ extension _Actions on _SftpPageState { if (editor.isNotEmpty) { // Use single quote to avoid escape final cmd = "$editor '${_getRemotePath(name)}'"; - await AppRoutes.ssh(spi: widget.spi, initCmd: cmd).go(context); + final args = SshPageArgs(spi: widget.args.spi, initCmd: cmd); + await SSHPage.route.go(context, args); await _listDir(); return; } @@ -324,7 +338,7 @@ extension _Actions on _SftpPageState { final localPath = _getLocalPath(remotePath); final completer = Completer(); final req = SftpReq( - widget.spi, + widget.args.spi, remotePath, localPath, SftpReqType.download, @@ -368,7 +382,7 @@ extension _Actions on _SftpPageState { SftpProvider.add( SftpReq( - widget.spi, + widget.args.spi, remotePath, _getLocalPath(remotePath), SftpReqType.download, @@ -604,7 +618,8 @@ extension _Actions on _SftpPageState { ); if (confirm != true) return; - await AppRoutes.ssh(spi: widget.spi, initCmd: cmd).go(context); + final args = SshPageArgs(spi: widget.args.spi, initCmd: cmd); + await SSHPage.route.go(context, args); _listDir(); } @@ -616,7 +631,7 @@ extension _Actions on _SftpPageState { /// Local file dir + server id + remote path String _getLocalPath(String remotePath) { - return Paths.file.joinPath(widget.spi.id).joinPath(remotePath); + return Paths.file.joinPath(widget.args.spi.id).joinPath(remotePath); } /// Only return true if the path is changed @@ -653,7 +668,7 @@ extension _Actions on _SftpPageState { // Only update history when success if (Stores.setting.sftpOpenLastPath.fetch()) { - Stores.history.sftpLastPath.put(widget.spi.id, listPath); + Stores.history.sftpLastPath.put(widget.args.spi.id, listPath); } return true; @@ -735,7 +750,7 @@ extension _Actions on _SftpPageState { final remotePath = '$remoteDir/$fileName'; Loggers.app.info('SFTP upload local: $path, remote: $remotePath'); SftpProvider.add( - SftpReq(widget.spi, remotePath, path, SftpReqType.upload), + SftpReq(widget.args.spi, remotePath, path, SftpReqType.upload), ); }, icon: const Icon(Icons.upload_file), @@ -817,7 +832,7 @@ extension _Actions on _SftpPageState { Widget _buildHomeBtn() { return IconButton( onPressed: () { - final user = widget.spi.user; + final user = widget.args.spi.user; _status.path.path = user != 'root' ? '/home/$user' : '/root'; _listDir(); }, diff --git a/lib/view/page/storage/sftp_mission.dart b/lib/view/page/storage/sftp_mission.dart index 67490fd2..c48c1c61 100644 --- a/lib/view/page/storage/sftp_mission.dart +++ b/lib/view/page/storage/sftp_mission.dart @@ -10,6 +10,11 @@ class SftpMissionPage extends StatefulWidget { @override State createState() => _SftpMissionPageState(); + + static const route = AppRouteNoArg( + page: SftpMissionPage.new, + path: '/sftp/mission', + ); } class _SftpMissionPageState extends State { diff --git a/lib/view/page/systemd.dart b/lib/view/page/systemd.dart index f4963651..5f78e3e6 100644 --- a/lib/view/page/systemd.dart +++ b/lib/view/page/systemd.dart @@ -4,24 +4,17 @@ import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/systemd.dart'; import 'package:server_box/data/provider/systemd.dart'; - -final class SystemdPageArgs { - final Spi spi; - - const SystemdPageArgs({ - required this.spi, - }); -} +import 'package:server_box/view/page/ssh/page.dart'; final class SystemdPage extends StatefulWidget { - final SystemdPageArgs args; + final SpiRequiredArgs args; const SystemdPage({ super.key, required this.args, }); - static const route = AppRouteArg( + static const route = AppRouteArg( page: SystemdPage.new, path: '/systemd', ); @@ -123,7 +116,8 @@ final class _SystemdPageState extends State { ); if (sure != true) return; - AppRoutes.ssh(spi: widget.args.spi, initCmd: cmd).go(context); + final args = SshPageArgs(spi: widget.args.spi, initCmd: cmd); + SSHPage.route.go(context, args); }, ); } diff --git a/lib/view/widget/server_func_btns.dart b/lib/view/widget/server_func_btns.dart index 00575d4b..ba9a39ff 100644 --- a/lib/view/widget/server_func_btns.dart +++ b/lib/view/widget/server_func_btns.dart @@ -9,7 +9,11 @@ import 'package:server_box/data/model/server/snippet.dart'; import 'package:server_box/data/provider/server.dart'; import 'package:server_box/data/provider/snippet.dart'; import 'package:server_box/data/res/store.dart'; +import 'package:server_box/view/page/container.dart'; import 'package:server_box/view/page/iperf.dart'; +import 'package:server_box/view/page/process.dart'; +import 'package:server_box/view/page/ssh/page.dart'; +import 'package:server_box/view/page/storage/sftp.dart'; import 'package:server_box/view/page/systemd.dart'; import 'package:server_box/core/route.dart'; @@ -27,9 +31,7 @@ class ServerFuncBtnsTopRight extends StatelessWidget { @override Widget build(BuildContext context) { return PopupMenu( - items: ServerFuncBtn.values - .map((e) => PopMenu.build(e, e.icon, e.toStr)) - .toList(), + items: ServerFuncBtn.values.map((e) => PopMenu.build(e, e.icon, e.toStr)).toList(), padding: const EdgeInsets.symmetric(horizontal: 10), onSelected: (val) => _onTapMoreBtns(val, spi, context), ); @@ -46,48 +48,64 @@ class ServerFuncBtns extends StatelessWidget { @override Widget build(BuildContext context) { - final btns = () { - try { - final vals = []; - final list = Stores.setting.serverFuncBtns.fetch(); - for (final idx in list) { - if (idx < 0 || idx >= ServerFuncBtn.values.length) continue; - vals.add(ServerFuncBtn.values[idx]); - } - return vals; - } catch (e) { - return ServerFuncBtn.values; - } - }(); - return Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: btns - .map( - (e) => Stores.setting.moveServerFuncs.fetch() - ? IconButton( - onPressed: () => _onTapMoreBtns(e, spi, context), - padding: EdgeInsets.zero, - tooltip: e.toStr, - icon: Icon(e.icon, size: 15), - ) - : Padding( - padding: const EdgeInsets.only(bottom: 13), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () => _onTapMoreBtns(e, spi, context), - padding: EdgeInsets.zero, - icon: Icon(e.icon, size: 17), - ), - Text(e.toStr, style: UIs.text11Grey) - ], - ), - ), - ) - .toList(), + final btns = this.btns; + if (btns.isEmpty) return UIs.placeholder; + + return SizedBox( + height: 70, + child: ListView.builder( + itemCount: btns.length, + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: 13), + itemBuilder: (context, index) { + final value = btns[index]; + final item = _buildItem(context, value); + return item.paddingSymmetric(horizontal: 7); + }, + ), ); } + + Widget _buildItem(BuildContext context, ServerFuncBtn e) { + final move = Stores.setting.moveServerFuncs.fetch(); + if (move) { + return IconButton( + onPressed: () => _onTapMoreBtns(e, spi, context), + padding: EdgeInsets.zero, + tooltip: e.toStr, + icon: Icon(e.icon, size: 15), + ); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 13), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => _onTapMoreBtns(e, spi, context), + padding: EdgeInsets.zero, + icon: Icon(e.icon, size: 17), + ), + Text(e.toStr, style: UIs.text11Grey) + ], + ), + ); + } + + List get btns { + try { + final vals = []; + final list = Stores.setting.serverFuncBtns.fetch(); + for (final idx in list) { + if (idx < 0 || idx >= ServerFuncBtn.values.length) continue; + vals.add(ServerFuncBtn.values[idx]); + } + return vals; + } catch (e) { + return ServerFuncBtn.values; + } + } } void _onTapMoreBtns( @@ -95,15 +113,22 @@ void _onTapMoreBtns( Spi spi, BuildContext context, ) async { + // final isMobile = ResponsiveBreakpoints.of(context).isMobile; switch (value) { // case ServerFuncBtn.pkg: // _onPkg(context, spi); // break; case ServerFuncBtn.sftp: - AppRoutes.sftp(spi: spi).checkGo( - context: context, - check: () => _checkClient(context, spi.id), - ); + if (!_checkClient(context, spi.id)) return; + final args = SftpPageArgs(spi: spi); + // if (isMobile) { + SftpPage.route.go(context, args); + // } else { + // SplitViewNavigator.of(context)?.replace( + // SftpPage.route.toWidget(args: args), + // ); + // } + break; case ServerFuncBtn.snippet: if (SnippetProvider.snippets.value.isEmpty) { @@ -141,32 +166,62 @@ void _onTapMoreBtns( ], ); if (sure != true) return; - AppRoutes.ssh(spi: spi, initSnippet: snippet).checkGo( - context: context, - check: () => _checkClient(context, spi.id), - ); + if (!_checkClient(context, spi.id)) return; + final args = SshPageArgs(spi: spi, initSnippet: snippet); + // if (isMobile) { + SSHPage.route.go(context, args); + // } else { + // SplitViewNavigator.of(context)?.replace( + // SSHPage.route.toWidget(args: args), + // ); + // } break; case ServerFuncBtn.container: - AppRoutes.docker(spi: spi).checkGo( - context: context, - check: () => _checkClient(context, spi.id), - ); + if (!_checkClient(context, spi.id)) return; + final args = SpiRequiredArgs(spi); + if (isMobile) { + ContainerPage.route.go(context, args); + } else { + SplitViewNavigator.of(context)?.replace( + ContainerPage.route.toWidget(args: args), + ); + } break; case ServerFuncBtn.process: - AppRoutes.process(spi: spi).checkGo( - context: context, - check: () => _checkClient(context, spi.id), - ); + if (!_checkClient(context, spi.id)) return; + final args = SpiRequiredArgs(spi); + // if (isMobile) { + ProcessPage.route.go(context, args); + // } else { + // SplitViewNavigator.of(context)?.replace( + // ProcessPage.route.toWidget(args: args), + // ); + // } break; case ServerFuncBtn.terminal: _gotoSSH(spi, context); break; case ServerFuncBtn.iperf: if (!_checkClient(context, spi.id)) return; - IPerfPage.route.go(context, IPerfPageArgs(spi: spi)); + final args = SpiRequiredArgs(spi); + // if (isMobile) { + IPerfPage.route.go(context, args); + // } else { + // SplitViewNavigator.of(context)?.replace( + // IPerfPage.route.toWidget(args: args), + // ); + // } break; case ServerFuncBtn.systemd: - SystemdPage.route.go(context, SystemdPageArgs(spi: spi)); + if (!_checkClient(context, spi.id)) return; + final args = SpiRequiredArgs(spi); + // if (isMobile) { + SystemdPage.route.go(context, args); + // } else { + // SplitViewNavigator.of(context)?.replace( + // SystemdPage.route.toWidget(args: args), + // ); + // } break; } } @@ -174,7 +229,8 @@ void _onTapMoreBtns( void _gotoSSH(Spi spi, BuildContext context) async { // run built-in ssh on macOS due to incompatibility if (isMobile || isMacOS) { - AppRoutes.ssh(spi: spi).go(context); + final args = SshPageArgs(spi: spi); + SSHPage.route.go(context, args); return; } final extraArgs = []; diff --git a/pubspec.lock b/pubspec.lock index 946b05cd..fb4814bc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -22,6 +22,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.11.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" + url: "https://pub.dev" + source: hosted + version: "0.11.3" + animations: + dependency: transitive + description: + name: animations + sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb + url: "https://pub.dev" + source: hosted + version: "2.0.11" ansicolor: dependency: transitive description: @@ -238,6 +254,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" circle_chart: dependency: "direct main" description: @@ -247,6 +271,14 @@ packages: url: "https://github.com/lollipopkit/circle_chart" source: git version: "0.0.3" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -328,6 +360,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "3486c470bb93313a9417f926c7dd694a2e349220992d7b9d14534dc49c15bba9" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + custom_lint_builder: + dependency: transitive + description: + name: custom_lint_builder + sha256: "42cdc41994eeeddab0d7a722c7093ec52bd0761921eeb2cbdbf33d192a234759" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "02450c3e45e2a6e8b26c4d16687596ab3c4644dd5792e3313aa9ceba5a49b7f5" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: bfe9b7a09c4775a587b58d10ebb871d4fe618237639b1e84d5ec62d7dfef25f9 + url: "https://pub.dev" + source: hosted + version: "1.0.0+6.11.0" dart_style: dependency: transitive description: @@ -394,7 +458,7 @@ packages: source: hosted version: "5.0.3" equatable: - dependency: transitive + dependency: "direct main" description: name: equatable sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" @@ -478,8 +542,8 @@ packages: dependency: "direct main" description: path: "." - ref: "v1.0.269" - resolved-ref: "338eb9135ea24f1d936ec023511a22d6c409576d" + ref: "v1.0.281" + resolved-ref: cff32a02af3622926d10c209fe07abe64408675c url: "https://github.com/lppcg/fl_lib" source: git version: "0.0.1" @@ -488,14 +552,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_adaptive_scaffold: - dependency: "direct main" - description: - name: flutter_adaptive_scaffold - sha256: "7279d74da2f2531a16d21c2ec327308778c3aedd672dfe4eaf3bf416463501f8" - url: "https://pub.dev" - source: hosted - version: "0.3.2" flutter_displaymode: dependency: "direct main" description: @@ -638,7 +694,7 @@ packages: source: hosted version: "5.5.0" flutter_riverpod: - dependency: transitive + dependency: "direct main" description: name: flutter_riverpod sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" @@ -663,8 +719,16 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "44c19278dd9d89292cf46e97dc0c1e52ce03275f40a97c5a348e802a924bf40e" + url: "https://pub.dev" + source: hosted + version: "2.5.7" freezed_annotation: - dependency: transitive + dependency: "direct main" description: name: freezed_annotation sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 @@ -735,6 +799,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b + url: "https://pub.dev" + source: hosted + version: "4.3.0" html: dependency: transitive description: @@ -983,6 +1055,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + multi_split_view: + dependency: transitive + description: + name: multi_split_view + sha256: "99c02f128e7423818d13b8f2e01e3027e953b35508019dcee214791bd0525db5" + url: "https://pub.dev" + source: hosted + version: "3.6.0" nested: dependency: transitive description: @@ -1200,6 +1280,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + responsive_framework: + dependency: "direct main" + description: + name: responsive_framework + sha256: a8e1c13d4ba980c60cbf6fa1e9907cd60662bf2585184d7c96ca46c43de91552 + url: "https://pub.dev" + source: hosted + version: "1.5.1" riverpod: dependency: transitive description: @@ -1208,14 +1296,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" - riverpod_annotation: + riverpod_analyzer_utils: dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: c6b8222b2b483cb87ae77ad147d6408f400c64f060df7a225b127f4afef4f8c8 + url: "https://pub.dev" + source: hosted + version: "0.5.8" + riverpod_annotation: + dependency: "direct main" description: name: riverpod_annotation sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 url: "https://pub.dev" source: hosted version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "63546d70952015f0981361636bf8f356d9cfd9d7f6f0815e3c07789a41233188" + url: "https://pub.dev" + source: hosted + version: "2.6.3" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "83e4caa337a9840469b7b9bd8c2351ce85abad80f570d84146911b32086fbd99" + url: "https://pub.dev" + source: hosted + version: "2.6.3" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" screen_retriever: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 60bb0f89..748d661a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: server_box description: server status & toolbox app. -publish_to: 'none' +publish_to: "none" version: 1.0.1128+1128 environment: @@ -16,6 +16,7 @@ dependencies: easy_isolate: ^1.3.0 intl: ^0.19.0 highlight: ^0.7.0 + equatable: ^2.0.7 flutter_highlight: ^0.7.0 code_text_field: ^1.1.0 shared_preferences: ^2.1.1 @@ -31,7 +32,10 @@ dependencies: choice: ^2.3.2 flutter_reorderable_grid_view: ^5.1.0 webdav_client_plus: ^1.0.2 - flutter_adaptive_scaffold: ^0.3.2 + freezed_annotation: ^2.4.4 + flutter_riverpod: ^2.6.1 + riverpod_annotation: ^2.6.1 + responsive_framework: ^1.5.1 dartssh2: git: url: https://github.com/lollipopkit/dartssh2 @@ -59,7 +63,7 @@ dependencies: fl_lib: git: url: https://github.com/lppcg/fl_lib - ref: v1.0.269 + ref: v1.0.281 dependency_overrides: # webdav_client_plus: @@ -76,9 +80,13 @@ dependency_overrides: dev_dependencies: flutter_native_splash: ^2.1.6 hive_generator: ^2.0.0 - build_runner: ^2.3.2 + build_runner: ^2.4.15 flutter_lints: ^5.0.0 json_serializable: ^6.8.0 + freezed: ^2.5.7 + riverpod_generator: ^2.6.3 + custom_lint: ^0.7.0 + riverpod_lint: ^2.6.3 flutter_test: sdk: flutter fl_build: @@ -132,21 +140,21 @@ flutter_native_splash: # size of the app. Only one parameter can be used, color and background_image cannot both be set. color: "#ffffff" #background_image: "assets/background.png" - # Optional parameters are listed below. To enable a parameter, uncomment the line by removing + # Optional parameters are listed below. To enable a parameter, uncomment the line by removing # the leading # character. - # The image parameter allows you to specify an image used in the splash screen. It must be a + # The image parameter allows you to specify an image used in the splash screen. It must be a # png file and should be sized for 4x pixel density. image: assets/app_icon.png # The color_dark, background_image_dark, and image_dark are parameters that set the background # and image when the device is in dark mode. If they are not specified, the app will use the - # parameters from above. If the image_dark parameter is specified, color_dark or + # parameters from above. If the image_dark parameter is specified, color_dark or # background_image_dark must be specified. color_dark and background_image_dark cannot both be # set. color_dark: "#121212" #background_image_dark: "assets/dark-background.png" #image_dark: assets/splash-invert.png - # The android, ios and web parameters can be used to disable generating a splash screen on a given + # The android, ios and web parameters can be used to disable generating a splash screen on a given # platform. #android: false #ios: false @@ -154,33 +162,33 @@ flutter_native_splash: # The position of the splash image can be set with android_gravity, ios_content_mode, and # web_image_mode parameters. All default to center. # - # android_gravity can be one of the following Android Gravity (see - # https://developer.android.com/reference/android/view/Gravity): bottom, center, + # android_gravity can be one of the following Android Gravity (see + # https://developer.android.com/reference/android/view/Gravity): bottom, center, # center_horizontal, center_vertical, clip_horizontal, clip_vertical, end, fill, fill_horizontal, # fill_vertical, left, right, start, or top. #android_gravity: center # - # ios_content_mode can be one of the following iOS UIView.ContentMode (see - # https://developer.apple.com/documentation/uikit/uiview/contentmode): scaleToFill, - # scaleAspectFit, scaleAspectFill, center, top, bottom, left, right, topLeft, topRight, + # ios_content_mode can be one of the following iOS UIView.ContentMode (see + # https://developer.apple.com/documentation/uikit/uiview/contentmode): scaleToFill, + # scaleAspectFit, scaleAspectFill, center, top, bottom, left, right, topLeft, topRight, # bottomLeft, or bottomRight. #ios_content_mode: center # # web_image_mode can be one of the following modes: center, contain, stretch, and cover. #web_image_mode: center - # To hide the notification bar, use the fullscreen parameter. Has no affect in web since web + # To hide the notification bar, use the fullscreen parameter. Has no affect in web since web # has no notification bar. Defaults to false. # NOTE: Unlike Android, iOS will not automatically show the notification bar when the app loads. # To show the notification bar, add the following code to your Flutter app: # WidgetsFlutterBinding.ensureInitialized(); # SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom, SystemUiOverlay.top]); #fullscreen: true - # If you have changed the name(s) of your info.plist file(s), you can specify the filename(s) + # If you have changed the name(s) of your info.plist file(s), you can specify the filename(s) # with the info_plist_files parameter. Remove only the # characters in the three lines below, # do not remove any spaces: info_plist_files: - - 'ios/Runner/Info-Debug.plist' - - 'ios/Runner/Info-Profile.plist' - - 'ios/Runner/Info-Release.plist' + - "ios/Runner/Info-Debug.plist" + - "ios/Runner/Info-Profile.plist" + - "ios/Runner/Info-Release.plist" # To enable support for Android 12, set the following parameter to true. Defaults to false. android12: true