feat: 优化UI&UE
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 87 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 773 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 772 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 240 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 568 B |
|
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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__';
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 773 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 520 B After Width: | Height: | Size: 608 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.4 KiB |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 228 KiB |
@@ -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": [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 78 KiB |