feat: 优化UI&UE

This commit is contained in:
chaos-zhu
2026-05-20 21:56:10 +08:00
parent 0c07a9b3f2
commit fbbbbd66be
60 changed files with 793 additions and 247 deletions
@@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="mobile"
android:label="EasyNode"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
@@ -2,11 +2,4 @@
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -2,11 +2,4 @@
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779284064927" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3059" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M456 484V160.1l-335.9 72V484H456zM512 484h391.8V64.2l-391.8 84V484zM456 540H120.2v251.9l335.9 72V540zM512 540v335.9l391.8 84V540H512z" fill="#00adef" p-id="3060"></path></svg>

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 568 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -32,6 +32,6 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="168" height="168"/>
</resources>
</document>
+2 -2
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Mobile</string>
<string>EasyNode</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>mobile</string>
<string>EasyNode</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
+164 -3
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -194,11 +196,11 @@ class _AppRootState extends ConsumerState<_AppRoot> {
return MaterialApp(
title: 'EasyNode',
themeMode: ThemeMode.system,
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.amber),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorSchemeSeed: Colors.indigo,
colorSchemeSeed: Colors.amber,
),
locale: ref.watch(localeProvider),
localizationsDelegates: const [
@@ -211,7 +213,166 @@ class _AppRootState extends ConsumerState<_AppRoot> {
localeResolutionCallback: (deviceLocale, supported) {
return AppLocalizations.resolve(deviceLocale, supported);
},
home: home,
home: _BrandedSplashGate(child: home),
);
}
}
class _BrandedSplashGate extends StatefulWidget {
const _BrandedSplashGate({required this.child});
final Widget child;
@override
State<_BrandedSplashGate> createState() => _BrandedSplashGateState();
}
class _BrandedSplashGateState extends State<_BrandedSplashGate>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _logoScale;
late final Animation<double> _logoFade;
late final Animation<Offset> _textOffset;
bool _showSplash = true;
Timer? _timer;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 850),
);
_logoScale = CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
);
_logoFade = CurvedAnimation(
parent: _controller,
curve: const Interval(0, 0.45, curve: Curves.easeOut),
);
_textOffset = Tween<Offset>(
begin: const Offset(0, 0.35),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.18, 0.7, curve: Curves.easeOutBack),
),
);
unawaited(_controller.forward());
_timer = Timer(const Duration(milliseconds: 1250), () {
if (mounted) setState(() => _showSplash = false);
});
}
@override
void dispose() {
_timer?.cancel();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 420),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, animation) {
final offset = Tween<Offset>(
begin: const Offset(0, 0.02),
end: Offset.zero,
).animate(animation);
return FadeTransition(
opacity: animation,
child: SlideTransition(position: offset, child: child),
);
},
child: _showSplash
? _BrandedSplash(
key: const ValueKey('splash'),
controller: _controller,
logoScale: _logoScale,
logoFade: _logoFade,
textOffset: _textOffset,
)
: KeyedSubtree(key: const ValueKey('home'), child: widget.child),
);
}
}
class _BrandedSplash extends StatelessWidget {
const _BrandedSplash({
super.key,
required this.controller,
required this.logoScale,
required this.logoFade,
required this.textOffset,
});
final AnimationController controller;
final Animation<double> logoScale;
final Animation<double> logoFade;
final Animation<Offset> textOffset;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final theme = Theme.of(context);
return Scaffold(
backgroundColor: colors.surfaceContainerLowest,
body: Center(
child: AnimatedBuilder(
animation: controller,
builder: (context, _) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
FadeTransition(
opacity: logoFade,
child: ScaleTransition(
scale: logoScale,
child: Container(
width: 104,
height: 104,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: colors.primary.withValues(alpha: 0.18),
blurRadius: 28,
offset: const Offset(0, 12),
),
],
),
child: Image.asset(
'assets/logo_v2_01.png',
fit: BoxFit.cover,
),
),
),
),
const SizedBox(height: 18),
SlideTransition(
position: textOffset,
child: FadeTransition(
opacity: logoFade,
child: Text(
'EasyNode',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
color: colors.onSurface,
),
),
),
),
],
);
},
),
),
);
}
}
+199 -121
View File
@@ -37,12 +37,11 @@ class _LoginPageState extends State<LoginPage> {
final FocusNode _userFocus = FocusNode();
final FocusNode _passwordFocus = FocusNode();
LoginExpiry _expiry = LoginExpiry.temporary;
LoginExpiry _expiry = LoginExpiry.currentDay;
bool _savePassword = false;
bool _httpRiskAccepted = false;
bool _showHttpRiskBanner = false;
bool _submitting = false;
bool _passwordVisible = false;
String? _errorMessage;
@override
@@ -130,117 +129,192 @@ class _LoginPageState extends State<LoginPage> {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final spacing = const SizedBox(height: 12);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final spacing = const SizedBox(height: 14);
return Scaffold(
appBar: AppBar(title: Text(l.tr('app.title'))),
backgroundColor: colors.surfaceContainerLowest,
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(l.tr('app.title'),
style: Theme.of(context).textTheme.headlineMedium),
Text(
l.tr('app.subtitle'),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
TextField(
key: const Key('field-server'),
controller: _serverCtrl,
focusNode: _serverFocus,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _userFocus.requestFocus(),
decoration: InputDecoration(
labelText: l.tr('login.serverAddress'),
hintText: l.tr('login.serverAddressHint'),
border: const OutlineInputBorder(),
),
),
spacing,
TextField(
key: const Key('field-username'),
controller: _userCtrl,
focusNode: _userFocus,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocus.requestFocus(),
decoration: InputDecoration(
labelText: l.tr('login.username'),
border: const OutlineInputBorder(),
),
),
spacing,
TextField(
key: const Key('field-password'),
controller: _pwdCtrl,
focusNode: _passwordFocus,
obscureText: !_passwordVisible,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
decoration: InputDecoration(
labelText: l.tr('login.password'),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
tooltip: _passwordVisible
? l.tr('login.hidePassword')
: l.tr('login.showPassword'),
icon: Icon(
_passwordVisible ? Icons.visibility_off : Icons.visibility,
),
onPressed: () =>
setState(() => _passwordVisible = !_passwordVisible),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 24),
children: [
_LoginHero(
title: l.tr('app.title'),
subtitle: l.tr('app.subtitle'),
),
),
const SizedBox(height: 24),
Card(
elevation: 0,
color: colors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(color: colors.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
key: const Key('field-server'),
controller: _serverCtrl,
focusNode: _serverFocus,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _userFocus.requestFocus(),
decoration: InputDecoration(
labelText: l.tr('login.serverAddress'),
hintText: l.tr('login.serverAddressHint'),
prefixIcon: const Icon(Icons.dns_outlined),
border: const OutlineInputBorder(),
),
),
spacing,
TextField(
key: const Key('field-username'),
controller: _userCtrl,
focusNode: _userFocus,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocus.requestFocus(),
decoration: InputDecoration(
labelText: l.tr('login.username'),
prefixIcon: const Icon(Icons.person_outline),
border: const OutlineInputBorder(),
),
),
spacing,
TextField(
key: const Key('field-password'),
controller: _pwdCtrl,
focusNode: _passwordFocus,
obscureText: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
decoration: InputDecoration(
labelText: l.tr('login.password'),
prefixIcon: const Icon(Icons.lock_outline),
border: const OutlineInputBorder(),
),
),
spacing,
TextField(
key: const Key('field-mfa'),
controller: _mfaCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: l.tr('login.mfa'),
prefixIcon: const Icon(Icons.pin_outlined),
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 18),
_ExpiryPicker(
value: _expiry,
onChanged: (value) => setState(() => _expiry = value),
),
const SizedBox(height: 8),
SwitchListTile(
key: const Key('switch-save-password'),
contentPadding: EdgeInsets.zero,
title: Text(l.tr('login.savePassword')),
secondary: const Icon(Icons.enhanced_encryption),
value: _savePassword,
onChanged: (value) =>
setState(() => _savePassword = value),
),
if (_showHttpRiskBanner) ...[
spacing,
_HttpRiskBanner(onConfirm: _acceptHttpRiskAndSubmit),
],
if (_errorMessage != null) ...[
spacing,
_ErrorBox(message: _errorMessage!),
],
const SizedBox(height: 18),
SizedBox(
height: 48,
child: FilledButton.icon(
key: const Key('btn-login'),
onPressed: _submitting ? null : _submit,
icon: _submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.login),
label: Text(l.tr('login.submit')),
),
),
],
),
),
),
],
),
spacing,
TextField(
key: const Key('field-mfa'),
controller: _mfaCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: l.tr('login.mfa'),
border: const OutlineInputBorder(),
),
),
spacing,
_ExpiryPicker(
value: _expiry,
onChanged: (value) => setState(() => _expiry = value),
),
SwitchListTile(
key: const Key('switch-save-password'),
contentPadding: EdgeInsets.zero,
title: Text(l.tr('login.savePassword')),
value: _savePassword,
onChanged: (value) => setState(() => _savePassword = value),
),
if (_showHttpRiskBanner) ...[
spacing,
_HttpRiskBanner(onConfirm: _acceptHttpRiskAndSubmit),
],
if (_errorMessage != null) ...[
spacing,
_ErrorBox(message: _errorMessage!),
],
const SizedBox(height: 16),
FilledButton(
key: const Key('btn-login'),
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l.tr('login.submit')),
),
],
),
),
),
);
}
}
class _LoginHero extends StatelessWidget {
const _LoginHero({required this.title, required this.subtitle});
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Row(
children: [
Container(
width: 56,
height: 56,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(16),
),
child: Image.asset(
'assets/logo_v2_01.png',
fit: BoxFit.cover,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
],
);
}
}
class _HttpRiskBanner extends StatelessWidget {
const _HttpRiskBanner({required this.onConfirm});
@@ -331,7 +405,6 @@ class _ExpiryPicker extends StatelessWidget {
final ValueChanged<LoginExpiry> onChanged;
static const _optionKeys = <LoginExpiry, String>{
LoginExpiry.temporary: 'login.expiry.temporary',
LoginExpiry.currentDay: 'login.expiry.currentDay',
LoginExpiry.threeDays: 'login.expiry.threeDays',
LoginExpiry.sevenDays: 'login.expiry.sevenDays',
@@ -340,24 +413,29 @@ class _ExpiryPicker extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return InputDecorator(
decoration: InputDecoration(
labelText: l.tr('login.sessionDuration'),
border: const OutlineInputBorder(),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<LoginExpiry>(
isExpanded: true,
value: value,
items: [
for (final entry in _optionKeys.entries)
DropdownMenuItem(value: entry.key, child: Text(l.tr(entry.value))),
],
onChanged: (v) {
if (v != null) onChanged(v);
},
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.tr('login.sessionDuration'),
style: theme.textTheme.labelLarge,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final entry in _optionKeys.entries)
ChoiceChip(
label: Text(l.tr(entry.value)),
selected: entry.key == value,
showCheckmark: false,
onSelected: (_) => onChanged(entry.key),
),
],
),
],
);
}
}
@@ -11,6 +11,7 @@ class ServerModel {
required this.port,
required this.username,
required this.authType,
required this.connectType,
required this.group,
required this.tag,
required this.expired,
@@ -23,6 +24,7 @@ class ServerModel {
final int port;
final String username;
final String authType;
final String connectType;
final String group;
final List<String> tag;
final bool expired;
@@ -53,6 +55,7 @@ class ServerModel {
port: port,
username: (json['username'] ?? '').toString(),
authType: (json['authType'] ?? '').toString(),
connectType: (json['connectType'] ?? '').toString(),
group: (json['group'] ?? '').toString(),
tag: tag,
expired: json['expired'] == true,
@@ -63,6 +66,7 @@ class ServerModel {
/// Whether the connect button should be enabled. The server marks hosts
/// without auth fields as `isConfig: false`.
bool get canConnect => isConfig;
bool get isWindows => connectType == 'rdp';
String get displayName => name.isEmpty ? host : name;
String get connectionLabel => '$username@$host:$port';
+284 -86
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../core/api/api_result.dart';
import '../../l10n/app_localizations.dart';
@@ -9,6 +10,7 @@ import '../../state/host_list_notifier.dart';
import '../../state/terminal_providers.dart';
import '../terminal/ssh_connection_config.dart';
import '../terminal/terminal_shell_page.dart';
import '../terminal/terminal_session_manager.dart';
import 'server_model.dart';
/// First bottom-nav tab — server list + connect action. Was a top-level
@@ -36,15 +38,18 @@ class _ServersTabState extends ConsumerState<ServersTab> {
Future<void> _refresh() => ref.read(hostListProvider.notifier).refresh();
Future<void> _connect(ServerModel server) async {
if (!server.canConnect) return;
final manager = ref.read(terminalSessionManagerProvider);
final existing = manager.firstForHost(server.id);
if (existing != null) {
manager.setActive(existing.id);
_openShell();
if (server.isWindows) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).tr('servers.windowsUnsupported'),
),
),
);
return;
}
if (!server.canConnect) return;
final manager = ref.read(terminalSessionManagerProvider);
setState(() => _connectingIds.add(server.id));
final SshConnectionConfig config;
@@ -130,10 +135,28 @@ class _ServersTabState extends ConsumerState<ServersTab> {
final l = AppLocalizations.of(context);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest,
appBar: AppBar(
centerTitle: true,
automaticallyImplyLeading: false,
title: Text(l.tr('servers.title')),
title: Row(
children: [
Container(
width: 34,
height: 34,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(10),
),
child: Image.asset(
'assets/logo_v2_01.png',
fit: BoxFit.cover,
),
),
const SizedBox(width: 10),
Text(l.tr('servers.title')),
],
),
actions: [
IconButton(
tooltip:
@@ -170,7 +193,7 @@ class _ServersTabState extends ConsumerState<ServersTab> {
Widget _buildBody(
AsyncValue<List<ServerModel>> hostsAsync,
Object manager,
TerminalSessionManager manager,
) {
final l = AppLocalizations.of(context);
return hostsAsync.when(
@@ -199,15 +222,18 @@ class _ServersTabState extends ConsumerState<ServersTab> {
},
data: (servers) {
final filtered = _filteredServers(servers);
final sessions = ref.read(terminalSessionManagerProvider).sessions.length;
final sessions = manager.sessions.length;
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
padding: const EdgeInsets.fromLTRB(16, 10, 16, 18),
children: [
_ActiveTerminalBanner(
count: sessions,
onTap: _openShell,
onCloseAll: _confirmCloseAllTerminals,
_ServersSummary(
total: servers.length,
visible: filtered.length,
activeTerminals: sessions,
onOpenTerminals: sessions > 0 ? _openShell : null,
onCloseAllTerminals:
sessions > 0 ? _confirmCloseAllTerminals : null,
),
AnimatedSize(
duration: const Duration(milliseconds: 220),
@@ -250,7 +276,10 @@ class _ServersTabState extends ConsumerState<ServersTab> {
_MessageState(message: l.tr('servers.emptyFiltered'))
else
for (final server in filtered)
_ServerCard(server: server, state: this),
_ServerCard(
server: server,
state: this,
),
],
);
},
@@ -280,59 +309,114 @@ class _ServersTabState extends ConsumerState<ServersTab> {
}
}
class _ActiveTerminalBanner extends StatelessWidget {
const _ActiveTerminalBanner({
required this.count,
required this.onTap,
required this.onCloseAll,
class _ServersSummary extends StatelessWidget {
const _ServersSummary({
required this.total,
required this.visible,
required this.activeTerminals,
required this.onOpenTerminals,
required this.onCloseAllTerminals,
});
final int count;
final VoidCallback onTap;
final VoidCallback onCloseAll;
final int total;
final int visible;
final int activeTerminals;
final VoidCallback? onOpenTerminals;
final VoidCallback? onCloseAllTerminals;
@override
Widget build(BuildContext context) {
if (count == 0) return const SizedBox.shrink();
final colors = Theme.of(context).colorScheme;
final l = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: colors.primaryContainer,
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
const Icon(Icons.layers),
const SizedBox(width: 10),
Expanded(
child: Text(
count == 1
? l.tr('servers.activeTerminalsOne')
: l.trf('servers.activeTerminalsMany', [count]),
),
),
const Icon(Icons.chevron_right),
const SizedBox(width: 4),
InkResponse(
onTap: onCloseAll,
radius: 16,
child: Tooltip(
message: l.tr('servers.closeAllTooltip'),
child: Icon(
Icons.close,
size: 18,
color: colors.onPrimaryContainer,
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
Expanded(
child: _MetricPill(
icon: Icons.dns_outlined,
value: visible == total ? '$total' : '$visible/$total',
color: colors.primaryContainer,
foreground: colors.onPrimaryContainer,
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricPill(
icon: Icons.terminal,
value: '$activeTerminals',
color: colors.tertiaryContainer,
foreground: colors.onTertiaryContainer,
onTap: onOpenTerminals,
trailing: activeTerminals > 0
? _CloseTerminalsButton(
tooltip: l.tr('servers.closeAllTooltip'),
foreground: colors.onTertiaryContainer,
onPressed: onCloseAllTerminals,
)
: null,
),
),
],
),
);
}
}
class _MetricPill extends StatelessWidget {
const _MetricPill({
required this.icon,
required this.value,
required this.color,
required this.foreground,
this.onTap,
this.trailing,
});
final IconData icon;
final String value;
final Color color;
final Color foreground;
final VoidCallback? onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Material(
color: color,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Stack(
alignment: Alignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18, color: foreground),
const SizedBox(width: 8),
Flexible(
child: Text(
value,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: foreground,
fontWeight: FontWeight.w700,
),
),
),
],
),
if (trailing != null)
Positioned(
right: -6,
child: trailing!,
),
],
),
],
),
),
),
@@ -340,8 +424,41 @@ class _ActiveTerminalBanner extends StatelessWidget {
}
}
class _CloseTerminalsButton extends StatelessWidget {
const _CloseTerminalsButton({
required this.tooltip,
required this.foreground,
required this.onPressed,
});
final String tooltip;
final Color foreground;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 32, height: 32),
style: IconButton.styleFrom(
backgroundColor: foreground.withValues(alpha: 0.10),
foregroundColor: foreground,
),
icon: const Icon(Icons.layers_clear, size: 16),
onPressed: onPressed,
),
);
}
}
class _ServerCard extends StatelessWidget {
const _ServerCard({required this.server, required this.state});
const _ServerCard({
required this.server,
required this.state,
});
final ServerModel server;
final _ServersTabState state;
@@ -350,42 +467,123 @@ class _ServerCard extends StatelessWidget {
Widget build(BuildContext context) {
final connecting = state._connectingIds.contains(server.id);
final l = AppLocalizations.of(context);
final colors = Theme.of(context).colorScheme;
final statusColor = server.canConnect
? colors.primary
: colors.onSurfaceVariant;
final iconAsset = server.isWindows
? 'assets/windows.svg'
: 'assets/linux.svg';
return Card(
key: Key('server-${server.id}'),
elevation: 0,
margin: const EdgeInsets.symmetric(vertical: 6),
child: ListTile(
title: Text(server.displayName, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
color: colors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: colors.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(server.connectionLabel, overflow: TextOverflow.ellipsis),
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 4,
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoChip(
label: server.authType.isEmpty
? l.tr('servers.authFallback')
: server.authType,
Container(
width: 42,
height: 42,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: SvgPicture.asset(
iconAsset,
fit: BoxFit.contain,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
server.displayName,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 3),
Text(
server.connectionLabel,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
_InfoChip(
label: server.authType.isEmpty
? l.tr('servers.authFallback')
: server.authType,
),
if (server.group.isNotEmpty)
_InfoChip(label: server.group),
],
),
),
const SizedBox(width: 10),
SizedBox(
height: 40,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 160),
child: connecting
? const SizedBox(
key: ValueKey('connecting'),
width: 96,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
)
: FilledButton.icon(
key: const ValueKey('connect-button'),
onPressed: server.canConnect || server.isWindows
? () => state._connect(server)
: null,
icon: Icon(
server.canConnect
? Icons.play_arrow
: Icons.lock_outline,
size: 18,
),
label: Text(state._actionText(server)),
),
),
),
if (server.group.isNotEmpty) _InfoChip(label: server.group),
],
),
],
),
trailing: connecting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: FilledButton.tonal(
onPressed: server.canConnect
? () => state._connect(server)
: null,
child: Text(state._actionText(server)),
),
),
);
}
@@ -147,6 +147,13 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
}
}
Future<void> _closeAllAndLeave(TerminalSessionManager manager) async {
await manager.closeAll();
if (mounted) {
Navigator.of(context).maybePop();
}
}
@override
Widget build(BuildContext context) {
final manager = ref.watch(terminalSessionManagerProvider);
@@ -173,6 +180,7 @@ class _TerminalShellPageState extends ConsumerState<TerminalShellPage> {
onSelect: manager.setActive,
onNew: _openServerMenu,
onClose: _closeActive,
onCloseAll: () => _closeAllAndLeave(manager),
onReconnect: active == null
? null
: () => manager.reconnect(active.id),
@@ -225,6 +233,7 @@ class _TerminalTopBar extends StatelessWidget {
required this.onSelect,
required this.onNew,
required this.onClose,
required this.onCloseAll,
required this.onReconnect,
});
@@ -234,6 +243,7 @@ class _TerminalTopBar extends StatelessWidget {
final ValueChanged<String> onSelect;
final ValueChanged<BuildContext> onNew;
final VoidCallback onClose;
final Future<void> Function() onCloseAll;
final VoidCallback? onReconnect;
Future<void> _openSessionsMenu(BuildContext context) async {
@@ -249,11 +259,27 @@ class _TerminalTopBar extends StatelessWidget {
final menuRect = Rect.fromLTWH(0, menuTop, 0, 0);
final overlayRect = Offset.zero & overlaySize;
final activeSession = active;
final l = AppLocalizations.of(context);
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromRect(menuRect, overlayRect),
constraints: BoxConstraints.tightFor(width: menuWidth),
items: [
if (sessions.length >= 2) ...[
PopupMenuItem<String>(
value: _kCloseAllTerminalsAction,
child: Row(
children: [
const Icon(Icons.layers_clear, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(l.tr('terminal.closeAllTerminals')),
),
],
),
),
const PopupMenuDivider(),
],
for (final session in sessions)
PopupMenuItem<String>(
value: session.id,
@@ -275,9 +301,31 @@ class _TerminalTopBar extends StatelessWidget {
),
],
);
if (selected != null) {
onSelect(selected);
if (selected == null) return;
if (selected == _kCloseAllTerminalsAction) {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l.tr('terminal.closeAllTitle')),
content: Text(l.trf('terminal.closeAllBodyMany', [sessions.length])),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l.tr('common.cancel')),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l.tr('common.closeAll')),
),
],
),
);
if (confirmed == true) {
await onCloseAll();
}
return;
}
onSelect(selected);
}
@override
@@ -554,3 +602,4 @@ String _statusText(AppLocalizations l, TerminalSessionStatus status) {
}
const double _kTopBarHeight = 52;
const String _kCloseAllTerminalsAction = '__close_all_terminals__';
+5 -1
View File
@@ -31,7 +31,7 @@ const Map<String, String> stringsEn = {
'login.failed': 'Login failed',
'login.httpRiskTitle': 'HTTP is not encrypted',
'login.httpRiskBody':
'Your token and session cookie can be intercepted. Use HTTPS when possible.',
'All your data may be stolen. It is recommended to use HTTPS.',
'login.errEmptyUsername': 'Please enter a username',
'login.errEmptyPassword': 'Please enter a password',
'login.errInvalidServer': 'Please enter a valid server address',
@@ -59,6 +59,7 @@ const Map<String, String> stringsEn = {
'servers.emptyFiltered': 'No matching servers.',
'servers.notConfigured': 'Not configured',
'servers.fetchSshFailed': 'Failed to get SSH config: {0}',
'servers.windowsUnsupported': 'Windows connections are not supported yet.',
'servers.activeTerminalsOne': '1 active terminal',
'servers.activeTerminalsMany': '{0} active terminals',
'servers.closeAllTitle': 'Close all terminals?',
@@ -85,6 +86,9 @@ const Map<String, String> stringsEn = {
'terminal.noActive': 'No active terminal',
'terminal.reconnect': 'Reconnect',
'terminal.closeTerminal': 'Close terminal',
'terminal.closeAllTerminals': 'Close all terminals',
'terminal.closeAllTitle': 'Close all terminals?',
'terminal.closeAllBodyMany': 'This will disconnect {0} active terminals.',
'terminal.newTerminal': 'New terminal',
'terminal.noServers': 'No servers available',
'terminal.loadServersFailed': 'Failed to load servers: {0}',
+5 -2
View File
@@ -29,14 +29,13 @@ const Map<String, String> stringsZh = {
'login.submit': '登录',
'login.failed': '登录失败',
'login.httpRiskTitle': 'HTTP 连接未加密',
'login.httpRiskBody': '您的 token 与 session cookie 可能被截获,建议尽量使用 HTTPS。',
'login.httpRiskBody': '您的所有数据可能会被盗取,建议使用 HTTPS。',
'login.errEmptyUsername': '请输入用户名',
'login.errEmptyPassword': '请输入密码',
'login.errInvalidServer': '请输入有效的服务端地址',
'login.errSchemeUnsupported': '服务端地址仅支持 http 或 https',
'login.errLoginGeneric': '登录失败',
'login.errMissingFields': '服务端登录响应缺少必要字段',
'login.expiry.temporary': '临时会话,1 小时',
'login.expiry.currentDay': '今天',
'login.expiry.threeDays': '3 天',
@@ -56,6 +55,7 @@ const Map<String, String> stringsZh = {
'servers.emptyFiltered': '没有匹配的服务器。',
'servers.notConfigured': '未配置',
'servers.fetchSshFailed': '获取 SSH 连接信息失败:{0}',
'servers.windowsUnsupported': 'Windows 连接暂不支持。',
'servers.activeTerminalsOne': '1 个活动终端',
'servers.activeTerminalsMany': '{0} 个活动终端',
'servers.closeAllTitle': '关闭所有终端?',
@@ -82,6 +82,9 @@ const Map<String, String> stringsZh = {
'terminal.noActive': '没有活动的终端',
'terminal.reconnect': '重新连接',
'terminal.closeTerminal': '关闭终端',
'terminal.closeAllTerminals': '关闭所有终端',
'terminal.closeAllTitle': '关闭所有终端?',
'terminal.closeAllBodyMany': '将断开 {0} 个活动终端。',
'terminal.newTerminal': '新建终端',
'terminal.noServers': '没有可用的服务器',
'terminal.loadServersFailed': '加载服务器失败:{0}',
+1 -1
View File
@@ -4,7 +4,7 @@ project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "mobile")
set(BINARY_NAME "EasyNode")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.mobile")
+2 -2
View File
@@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "mobile");
gtk_header_bar_set_title(header_bar, "EasyNode");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "mobile");
gtk_window_set_title(window, "EasyNode");
}
gtk_window_set_default_size(window, 1280, 720);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 B

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

+1 -1
View File
@@ -5,7 +5,7 @@
// 'flutter create' template.
// The application's name. By default this is also the title of the Flutter window.
PRODUCT_NAME = mobile
PRODUCT_NAME = EasyNode
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.example.mobile
+56
View File
@@ -243,6 +243,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -429,6 +437,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
description:
@@ -477,6 +493,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
pinenacl:
dependency: transitive
description:
@@ -682,6 +706,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e
url: "https://pub.dev"
source: hosted
version: "1.2.3"
vector_math:
dependency: transitive
description:
@@ -722,6 +770,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
xterm:
dependency: "direct main"
description:
+5
View File
@@ -23,6 +23,7 @@ dependencies:
xterm: ^4.0.0
uuid: ^4.5.1
flutter_riverpod: ^2.5.1
flutter_svg: ^2.2.4
dev_dependencies:
flutter_test:
@@ -31,3 +32,7 @@ dev_dependencies:
flutter:
uses-material-design: true
assets:
- assets/logo_v2_01.png
- assets/linux.svg
- assets/windows.svg
Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 228 KiB

+3 -3
View File
@@ -1,11 +1,11 @@
{
"name": "mobile",
"short_name": "mobile",
"name": "EasyNode",
"short_name": "EasyNode",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"description": "EasyNode mobile app.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
+2 -2
View File
@@ -1,10 +1,10 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.14)
project(mobile LANGUAGES CXX)
project(EasyNode LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "mobile")
set(BINARY_NAME "EasyNode")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
+4 -4
View File
@@ -90,12 +90,12 @@ BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "com.example" "\0"
VALUE "FileDescription", "mobile" "\0"
VALUE "FileDescription", "EasyNode" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "mobile" "\0"
VALUE "InternalName", "EasyNode" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
VALUE "OriginalFilename", "mobile.exe" "\0"
VALUE "ProductName", "mobile" "\0"
VALUE "OriginalFilename", "EasyNode.exe" "\0"
VALUE "ProductName", "EasyNode" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"
END
END
+1 -1
View File
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project);
Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720);
if (!window.Create(L"mobile", origin, size)) {
if (!window.Create(L"EasyNode", origin, size)) {
return EXIT_FAILURE;
}
window.SetQuitOnClose(true);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 78 KiB