feat: 添加调试版服务端地址切换功能

实现登录页调试版可见的服务端设置弹窗,支持自定义 IP 和端口,优先级高于编译期默认地址。新增相关状态管理、存储逻辑及设置入口图标,修改 ApiClient 以支持动态传入 Base URL,确保切换后无需重启 App 立即生效。
This commit is contained in:
andyesfly committed 2026-10-03 10:50:12 +08:00
1 parent e14d371cc8
commit cc6dc6385a
6 files changed
+327 -5

No files matched your search

@@ -1,10 +1,14 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/env.dart';
import '../../../core/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
@@ -107,11 +111,45 @@ class _LoginPageState extends ConsumerState<LoginPage> {
),
),
),
// 测试用「服务端设置」入口,仅调试版可见
if (kDebugMode)
Positioned(
top: 0,
right: 0,
child: SafeArea(
child: GestureDetector(
onTap: _openServerSettings,
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.all(12),
child: XIcon(
'settings',
size: 22,
color: AppColors.textMuted,
),
),
),
),
),
],
),
);
}
Future<void> _openServerSettings() {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppRadius.lg),
),
),
builder: (_) => const _ServerSettingsSheet(),
);
}
Widget _buildBrand() {
return Column(
children: [
@@ -276,4 +314,202 @@ class _SoftBlob extends StatelessWidget {
),
);
}
}
/// 调试版「服务端设置」弹窗:只让改 IP 与端口,
/// 协议与路径沿用 [Env.apiBaseUrl] 的编译期配置。
class _ServerSettingsSheet extends ConsumerStatefulWidget {
const _ServerSettingsSheet();
@override
ConsumerState<_ServerSettingsSheet> createState() =>
_ServerSettingsSheetState();
}
class _ServerSettingsSheetState extends ConsumerState<_ServerSettingsSheet> {
late final TextEditingController _host;
late final TextEditingController _port;
String _error = '';
@override
void initState() {
super.initState();
// 已保存过就回填保存值,否则预填编译期默认值
final saved = ref.read(serverAddressProvider);
_host = TextEditingController(
text: saved.host.isEmpty ? Env.defaultHost : saved.host,
);
_port = TextEditingController(
text: saved.port.isEmpty ? '${Env.defaultPort}' : saved.port,
);
}
@override
void dispose() {
_host.dispose();
_port.dispose();
super.dispose();
}
String get _preview {
final host = _host.text.trim();
return Env.buildBaseUrl(
host.isEmpty ? Env.defaultHost : host,
_port.text.trim(),
);
}
Future<void> _save() async {
final host = _host.text.trim();
final port = _port.text.trim();
if (host.isEmpty) {
setState(() => _error = '请输入服务端 IP');
return;
}
if (host.contains('://') || host.contains('/') || host.contains(':')) {
setState(() => _error = '只填 IP 或域名,不要带 http:// 和端口');
return;
}
final parsedPort = int.tryParse(port);
if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
setState(() => _error = '端口需为 1 - 65535 的整数');
return;
}
// 保存后 serverAddressProvider 变化会重建 ApiClient,立即生效
await ref.read(serverAddressProvider.notifier).save(host, port);
if (!mounted) return;
Navigator.of(context).pop();
showAppToast('已切换到 ${Env.buildBaseUrl(host, port)}', success: true);
}
Future<void> _reset() async {
await ref.read(serverAddressProvider.notifier).reset();
if (!mounted) return;
Navigator.of(context).pop();
showAppToast('已恢复默认地址', success: true);
}
@override
Widget build(BuildContext context) {
return Padding(
// 键盘弹出时把弹窗顶上去
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('服务端设置', style: AppTypography.titleSm),
const SizedBox(height: 6),
const Text(
'仅调试版可见。保存后立即生效,无需重启 App。',
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
),
const SizedBox(height: 20),
_buildField(
label: '服务端 IP',
controller: _host,
hintText: '如 192.168.3.29',
keyboardType: TextInputType.url,
),
const SizedBox(height: 16),
_buildField(
label: '端口',
controller: _port,
hintText: '如 7001',
keyboardType: TextInputType.number,
),
const SizedBox(height: 14),
// 保存前实时预览拼接结果,避免地址拼错
ListenableBuilder(
listenable: Listenable.merge([_host, _port]),
builder: (context, _) => Text(
'生效地址:$_preview',
style: AppTypography.dataMono.copyWith(
color: AppColors.textMuted,
),
),
),
if (_error.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
_error,
style: const TextStyle(
fontSize: 13,
color: AppColors.statusError,
),
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: XPrimaryButton(
label: '恢复默认',
onPressed: _reset,
background: AppColors.surfaceBright,
foreground: AppColors.onSurfaceVariant,
borderColor: AppColors.borderRule,
),
),
const SizedBox(width: 12),
Expanded(
child: XPrimaryButton(label: '保存', onPressed: _save),
),
],
),
],
),
),
);
}
Widget _buildField({
required String label,
required TextEditingController controller,
required String hintText,
required TextInputType keyboardType,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppTypography.labelBold.copyWith(
color: AppColors.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: TextField(
controller: controller,
keyboardType: keyboardType,
autocorrect: false,
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: hintText,
hintStyle: const TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
),
],
);
}
}