505 lines
15 KiB
Dart
505 lines
15 KiB
Dart
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:flutter/services.dart';
|
||
|
|
import 'package:forui/forui.dart';
|
||
|
|
|
||
|
|
import '../constants/breakpoints.dart';
|
||
|
|
import 'icon_picker_dialog.dart';
|
||
|
|
import 'app_inline_alert.dart';
|
||
|
|
import 'app_loading_ring.dart';
|
||
|
|
import 'server_avatar.dart';
|
||
|
|
import 'setting_select.dart';
|
||
|
|
|
||
|
|
class ConnectionForm extends StatefulWidget {
|
||
|
|
final String mode;
|
||
|
|
final String? initialUrl;
|
||
|
|
final String? initialUsername;
|
||
|
|
final String? initialPassword;
|
||
|
|
final String? initialDisplayName;
|
||
|
|
final String? initialIconUrl;
|
||
|
|
final void Function(
|
||
|
|
String url,
|
||
|
|
String username,
|
||
|
|
String password,
|
||
|
|
String? displayName,
|
||
|
|
String iconUrl,
|
||
|
|
)
|
||
|
|
onConnect;
|
||
|
|
final VoidCallback? onCancel;
|
||
|
|
final bool loading;
|
||
|
|
final String? error;
|
||
|
|
|
||
|
|
const ConnectionForm({
|
||
|
|
super.key,
|
||
|
|
this.mode = 'add',
|
||
|
|
this.initialUrl,
|
||
|
|
this.initialUsername,
|
||
|
|
this.initialPassword,
|
||
|
|
this.initialDisplayName,
|
||
|
|
this.initialIconUrl,
|
||
|
|
required this.onConnect,
|
||
|
|
this.onCancel,
|
||
|
|
this.loading = false,
|
||
|
|
this.error,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<ConnectionForm> createState() => _ConnectionFormState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _ConnectionFormState extends State<ConnectionForm> {
|
||
|
|
final _formKey = GlobalKey<FormState>();
|
||
|
|
late String _protocol;
|
||
|
|
late final TextEditingController _host;
|
||
|
|
late final TextEditingController _port;
|
||
|
|
late final TextEditingController _displayName;
|
||
|
|
late final TextEditingController _username;
|
||
|
|
late final TextEditingController _password;
|
||
|
|
bool _obscurePassword = true;
|
||
|
|
late String _iconUrl;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
final parsed = _parseUrl(widget.initialUrl ?? '');
|
||
|
|
_protocol = parsed['protocol'] ?? 'https';
|
||
|
|
_host = TextEditingController(text: parsed['host'] ?? '');
|
||
|
|
_port = TextEditingController(text: parsed['port'] ?? '');
|
||
|
|
_displayName = TextEditingController(text: widget.initialDisplayName ?? '');
|
||
|
|
_username = TextEditingController(text: widget.initialUsername ?? '');
|
||
|
|
_password = TextEditingController(text: widget.initialPassword ?? '');
|
||
|
|
_iconUrl = widget.initialIconUrl ?? '';
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
_host.dispose();
|
||
|
|
_port.dispose();
|
||
|
|
_displayName.dispose();
|
||
|
|
_username.dispose();
|
||
|
|
_password.dispose();
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
static Map<String, String?> _parseUrl(String input) {
|
||
|
|
final trimmed = input.trim();
|
||
|
|
if (trimmed.isEmpty) return {'protocol': 'https', 'host': '', 'port': null};
|
||
|
|
try {
|
||
|
|
final toParse = trimmed.contains('://') ? trimmed : 'https://$trimmed';
|
||
|
|
final uri = Uri.parse(toParse);
|
||
|
|
final scheme = (uri.scheme == 'http' || uri.scheme == 'https')
|
||
|
|
? uri.scheme
|
||
|
|
: 'https';
|
||
|
|
return {
|
||
|
|
'protocol': scheme,
|
||
|
|
'host': uri.host.isEmpty ? trimmed : uri.host,
|
||
|
|
'port': uri.hasPort ? uri.port.toString() : null,
|
||
|
|
};
|
||
|
|
} catch (_) {
|
||
|
|
return {'protocol': 'https', 'host': trimmed, 'port': null};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void _syncFromUrl(String value) {
|
||
|
|
if (!value.contains('://')) return;
|
||
|
|
final parsed = _parseUrl(value);
|
||
|
|
final host = parsed['host'] ?? '';
|
||
|
|
if (host.isEmpty || host == value.trim()) return;
|
||
|
|
|
||
|
|
setState(() {
|
||
|
|
if (parsed['protocol'] != null) _protocol = parsed['protocol']!;
|
||
|
|
});
|
||
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
|
|
if (!mounted) return;
|
||
|
|
_host.text = host;
|
||
|
|
_host.selection = TextSelection.fromPosition(
|
||
|
|
TextPosition(offset: host.length),
|
||
|
|
);
|
||
|
|
_port.text = parsed['port'] ?? '';
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
String _buildUrl() {
|
||
|
|
final host = _host.text.trim();
|
||
|
|
final port = _port.text.trim();
|
||
|
|
return port.isNotEmpty ? '$_protocol://$host:$port' : '$_protocol://$host';
|
||
|
|
}
|
||
|
|
|
||
|
|
void _submit() {
|
||
|
|
if (!_formKey.currentState!.validate()) return;
|
||
|
|
final url = _buildUrl();
|
||
|
|
final displayName = _displayName.text.trim();
|
||
|
|
widget.onConnect(
|
||
|
|
url,
|
||
|
|
_username.text.trim(),
|
||
|
|
_password.text,
|
||
|
|
displayName.isEmpty ? null : displayName,
|
||
|
|
_iconUrl,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _pickIcon() async {
|
||
|
|
if (widget.loading) return;
|
||
|
|
final result = await showIconPickerDialog(
|
||
|
|
context,
|
||
|
|
currentIconUrl: _iconUrl,
|
||
|
|
);
|
||
|
|
if (result == null) return;
|
||
|
|
if (!mounted) return;
|
||
|
|
setState(() => _iconUrl = result);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
final isEdit = widget.mode == 'edit';
|
||
|
|
final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width);
|
||
|
|
final protocolTextStyle = theme.textTheme.bodyLarge?.copyWith(
|
||
|
|
color: theme.colorScheme.onSurface,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
);
|
||
|
|
|
||
|
|
return Column(
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: [
|
||
|
|
Text(isEdit ? '编辑服务器' : '添加服务器', style: theme.textTheme.titleLarge),
|
||
|
|
const SizedBox(height: 4),
|
||
|
|
Text(
|
||
|
|
isEdit ? '修改 Emby 服务器信息并重新登录' : '输入 Emby 服务器信息以连接',
|
||
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
||
|
|
color: theme.colorScheme.onSurfaceVariant,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 20),
|
||
|
|
Form(
|
||
|
|
key: _formKey,
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: [
|
||
|
|
if (isCompact)
|
||
|
|
_buildCompactEndpointFields(protocolTextStyle)
|
||
|
|
else
|
||
|
|
_buildDesktopEndpointFields(protocolTextStyle),
|
||
|
|
const SizedBox(height: 10),
|
||
|
|
_DisplayNameWithAvatar(
|
||
|
|
iconUrl: _iconUrl,
|
||
|
|
serverName: _displayName.text.trim().isNotEmpty
|
||
|
|
? _displayName.text.trim()
|
||
|
|
: (_host.text.trim().isNotEmpty ? _host.text.trim() : 'S'),
|
||
|
|
controller: _displayName,
|
||
|
|
enabled: !widget.loading,
|
||
|
|
onPick: _pickIcon,
|
||
|
|
),
|
||
|
|
const SizedBox(height: 10),
|
||
|
|
TextFormField(
|
||
|
|
controller: _username,
|
||
|
|
decoration: const InputDecoration(labelText: '用户名'),
|
||
|
|
enabled: !widget.loading,
|
||
|
|
autofillHints: const [AutofillHints.username],
|
||
|
|
textInputAction: TextInputAction.next,
|
||
|
|
autocorrect: false,
|
||
|
|
enableSuggestions: false,
|
||
|
|
validator: (v) =>
|
||
|
|
(v == null || v.trim().isEmpty) ? '用户名不能为空' : null,
|
||
|
|
),
|
||
|
|
const SizedBox(height: 10),
|
||
|
|
TextFormField(
|
||
|
|
controller: _password,
|
||
|
|
decoration: InputDecoration(
|
||
|
|
labelText: '密码',
|
||
|
|
suffixIcon: IconButton(
|
||
|
|
icon: Icon(
|
||
|
|
_obscurePassword
|
||
|
|
? Icons.visibility_outlined
|
||
|
|
: Icons.visibility_off_outlined,
|
||
|
|
),
|
||
|
|
onPressed: widget.loading
|
||
|
|
? null
|
||
|
|
: () => setState(
|
||
|
|
() => _obscurePassword = !_obscurePassword,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
enabled: !widget.loading,
|
||
|
|
obscureText: _obscurePassword,
|
||
|
|
autofillHints: const [AutofillHints.password],
|
||
|
|
textInputAction: TextInputAction.done,
|
||
|
|
autocorrect: false,
|
||
|
|
enableSuggestions: false,
|
||
|
|
onFieldSubmitted: (_) {
|
||
|
|
if (!widget.loading) _submit();
|
||
|
|
},
|
||
|
|
),
|
||
|
|
if (widget.error != null) ...[
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
AppInlineAlert(message: widget.error!),
|
||
|
|
],
|
||
|
|
const SizedBox(height: 20),
|
||
|
|
_buildActions(isCompact),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildDesktopEndpointFields(TextStyle? protocolTextStyle) {
|
||
|
|
return Row(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
SizedBox(
|
||
|
|
width: 124,
|
||
|
|
child: _ProtocolSelect(
|
||
|
|
value: _protocol,
|
||
|
|
enabled: !widget.loading,
|
||
|
|
onChanged: (v) {
|
||
|
|
if (v != null) setState(() => _protocol = v);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Expanded(child: _buildHostField()),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
SizedBox(width: 80, child: _buildPortField()),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildCompactEndpointFields(TextStyle? protocolTextStyle) {
|
||
|
|
return Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: [
|
||
|
|
_buildHostField(),
|
||
|
|
const SizedBox(height: 10),
|
||
|
|
Row(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Expanded(
|
||
|
|
flex: 5,
|
||
|
|
child: _ProtocolSelect(
|
||
|
|
value: _protocol,
|
||
|
|
enabled: !widget.loading,
|
||
|
|
onChanged: (v) {
|
||
|
|
if (v != null) setState(() => _protocol = v);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
Expanded(flex: 6, child: _buildPortField()),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildHostField() {
|
||
|
|
return Focus(
|
||
|
|
onFocusChange: (hasFocus) {
|
||
|
|
if (!hasFocus) _syncFromUrl(_host.text);
|
||
|
|
},
|
||
|
|
child: TextFormField(
|
||
|
|
controller: _host,
|
||
|
|
decoration: const InputDecoration(
|
||
|
|
labelText: '服务器地址',
|
||
|
|
hintText: 'example.com',
|
||
|
|
),
|
||
|
|
enabled: !widget.loading,
|
||
|
|
keyboardType: TextInputType.url,
|
||
|
|
textInputAction: TextInputAction.next,
|
||
|
|
autocorrect: false,
|
||
|
|
enableSuggestions: false,
|
||
|
|
onChanged: _syncFromUrl,
|
||
|
|
validator: (v) => (v == null || v.trim().isEmpty) ? '服务器地址不能为空' : null,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildPortField() {
|
||
|
|
return TextFormField(
|
||
|
|
controller: _port,
|
||
|
|
decoration: const InputDecoration(labelText: '端口', hintText: '443'),
|
||
|
|
enabled: !widget.loading,
|
||
|
|
keyboardType: TextInputType.number,
|
||
|
|
textInputAction: TextInputAction.next,
|
||
|
|
autocorrect: false,
|
||
|
|
enableSuggestions: false,
|
||
|
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||
|
|
validator: (v) {
|
||
|
|
if (v == null || v.trim().isEmpty) return null;
|
||
|
|
final n = int.tryParse(v.trim());
|
||
|
|
if (n == null || n < 1 || n > 65535) return '1-65535';
|
||
|
|
return null;
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildActions(bool isCompact) {
|
||
|
|
final saveButton = FButton(
|
||
|
|
variant: FButtonVariant.primary,
|
||
|
|
onPress: widget.loading ? null : _submit,
|
||
|
|
child: widget.loading
|
||
|
|
? const AppLoadingRing(size: 16, color: Colors.white)
|
||
|
|
: const Text('保存'),
|
||
|
|
);
|
||
|
|
|
||
|
|
if (!isCompact) {
|
||
|
|
return Row(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
||
|
|
children: [
|
||
|
|
if (widget.onCancel != null)
|
||
|
|
FButton(
|
||
|
|
variant: FButtonVariant.outline,
|
||
|
|
onPress: widget.loading ? null : widget.onCancel,
|
||
|
|
child: const Text('取消'),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
saveButton,
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: [
|
||
|
|
SizedBox(height: 48, child: saveButton),
|
||
|
|
if (widget.onCancel != null) ...[
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
SizedBox(
|
||
|
|
height: 44,
|
||
|
|
child: FButton(
|
||
|
|
variant: FButtonVariant.outline,
|
||
|
|
onPress: widget.loading ? null : widget.onCancel,
|
||
|
|
child: const Text('取消'),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _ProtocolSelect extends StatelessWidget {
|
||
|
|
final String value;
|
||
|
|
final bool enabled;
|
||
|
|
final ValueChanged<String?> onChanged;
|
||
|
|
|
||
|
|
const _ProtocolSelect({
|
||
|
|
required this.value,
|
||
|
|
required this.enabled,
|
||
|
|
required this.onChanged,
|
||
|
|
});
|
||
|
|
|
||
|
|
static const _protocols = ['https', 'http'];
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
return Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
'协议',
|
||
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
||
|
|
color: theme.colorScheme.onSurfaceVariant,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 6),
|
||
|
|
SettingSelect<String>(
|
||
|
|
value: value,
|
||
|
|
enabled: enabled,
|
||
|
|
options: _protocols,
|
||
|
|
labelOf: (value) => value.toUpperCase(),
|
||
|
|
onChanged: onChanged,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class _DisplayNameWithAvatar extends StatelessWidget {
|
||
|
|
final String iconUrl;
|
||
|
|
final String serverName;
|
||
|
|
final TextEditingController controller;
|
||
|
|
final bool enabled;
|
||
|
|
final VoidCallback onPick;
|
||
|
|
|
||
|
|
const _DisplayNameWithAvatar({
|
||
|
|
required this.iconUrl,
|
||
|
|
required this.serverName,
|
||
|
|
required this.controller,
|
||
|
|
required this.enabled,
|
||
|
|
required this.onPick,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final theme = Theme.of(context);
|
||
|
|
|
||
|
|
return Row(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||
|
|
children: [
|
||
|
|
Tooltip(
|
||
|
|
message: '点击更换图标',
|
||
|
|
child: InkWell(
|
||
|
|
onTap: enabled ? onPick : null,
|
||
|
|
borderRadius: BorderRadius.circular(10),
|
||
|
|
child: SizedBox(
|
||
|
|
width: 62,
|
||
|
|
height: 62,
|
||
|
|
child: Stack(
|
||
|
|
children: [
|
||
|
|
Positioned(
|
||
|
|
left: 0,
|
||
|
|
top: 0,
|
||
|
|
child: ServerAvatar(
|
||
|
|
name: serverName,
|
||
|
|
iconUrl: iconUrl,
|
||
|
|
size: 56,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Positioned(
|
||
|
|
right: 0,
|
||
|
|
bottom: 0,
|
||
|
|
child: Container(
|
||
|
|
padding: const EdgeInsets.all(3),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: theme.colorScheme.surface,
|
||
|
|
shape: BoxShape.circle,
|
||
|
|
border: Border.all(
|
||
|
|
color: theme.dividerColor,
|
||
|
|
width: 0.5,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
child: Icon(
|
||
|
|
Icons.edit_outlined,
|
||
|
|
size: 11,
|
||
|
|
color: theme.colorScheme.onSurfaceVariant,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
Expanded(
|
||
|
|
child: TextFormField(
|
||
|
|
controller: controller,
|
||
|
|
decoration: const InputDecoration(
|
||
|
|
labelText: '展示名称',
|
||
|
|
hintText: '留空则使用服务器名称',
|
||
|
|
),
|
||
|
|
enabled: enabled,
|
||
|
|
textInputAction: TextInputAction.next,
|
||
|
|
autocorrect: false,
|
||
|
|
enableSuggestions: false,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|