feat: 添加调试版服务端地址切换功能
实现登录页调试版可见的服务端设置弹窗,支持自定义 IP 和端口,优先级高于编译期默认地址。新增相关状态管理、存储逻辑及设置入口图标,修改 ApiClient 以支持动态传入 Base URL,确保切换后无需重启 App 立即生效。
This commit is contained in:
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user