Files
2026-07-14 11:11:36 +08:00

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,
),
),
],
);
}
}