merge: 合并 K13 开发服务器地址设置

This commit is contained in:
Zhou Weidong committed 2026-10-17 22:54:25 +08:00
commit f95325d581
14 files changed
+590 -27

No files matched your search

+26 -6
View File
@@ -15,18 +15,28 @@ class AppConfig {
/// `ipconfig getifaddr en0` 重新确认并改这里;
/// 也可以换成 Bonjour 名字 `http://<LocalHostName>.local:8081`(本机是
/// `jiangdeMacBook-Air.local`),换网络不用改,但部分办公网会拦组播解析。
static const String devHostBaseUrl =
'http://jiangdeMacBook-Air.local:8081';
static const String devHostBaseUrl = 'http://jiangdeMacBook-Air.local:8081';
static const String _loopbackBaseUrl = 'http://127.0.0.1:8081';
static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL');
static String get apiBaseUrl =>
_overrideBaseUrl.isNotEmpty ? _overrideBaseUrl : _defaultBaseUrl;
static String _runtimeBaseUrl = '';
static final String _defaultBaseUrl =
_runsOnDevMachine ? _loopbackBaseUrl : devHostBaseUrl;
/// Build-time server URL supplied through `--dart-define=API_BASE_URL`.
static String get compiledBaseUrl => _overrideBaseUrl;
/// Existing platform-aware fallback used when no runtime or build override exists.
static String get defaultBaseUrl => _defaultBaseUrl;
/// Effective server URL, including the debug-only runtime override.
static String get apiBaseUrl => _runtimeBaseUrl.isNotEmpty
? _runtimeBaseUrl
: (compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl);
static final String _defaultBaseUrl = _runsOnDevMachine
? _loopbackBaseUrl
: devHostBaseUrl;
/// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。
/// Android 没有等价的探测方式,统一按真机处理(模拟器用
@@ -39,6 +49,16 @@ class AppConfig {
(defaultTargetPlatform == TargetPlatform.iOS &&
Platform.environment.containsKey('SIMULATOR_DEVICE_NAME'));
/// Applies an already normalized runtime URL. Passing null clears it.
static void applyRuntimeBaseUrl(String? value) {
_runtimeBaseUrl = value ?? '';
}
/// Clears the in-memory runtime override, mainly for tests and restarts.
static void clearRuntimeBaseUrl() {
_runtimeBaseUrl = '';
}
static const Duration requestTimeout = Duration(seconds: 8);
/// 后端返回的是相对路径(/files/...),这里补上服务地址。
+86
View File
@@ -0,0 +1,86 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app_config.dart';
/// Debug-only runtime server endpoint persisted outside build-time defines.
abstract final class ServerEndpoint {
/// Preference key used for the runtime server address.
static const String preferenceKey = 'dev.api_base_url';
/// Returns a normalized http(s) URL, or null for invalid input.
static String? normalize(String? raw) {
final value = raw?.trim() ?? '';
final uri = Uri.tryParse(value);
if (uri == null ||
(uri.scheme != 'http' && uri.scheme != 'https') ||
uri.host.isEmpty) {
return null;
}
return value.replaceFirst(RegExp(r'/+$'), '');
}
/// Resolves runtime > dart-define > default using normalized valid values.
static String resolve({
required String? stored,
required String compiled,
required String fallback,
}) {
return normalize(stored) ??
normalize(compiled) ??
normalize(fallback) ??
fallback;
}
/// Loads the persisted override before the app creates its ApiClient.
///
/// In release builds this returns immediately without reading preferences.
static Future<void> load({
bool debug = kDebugMode,
SharedPreferencesAsync? preferences,
}) async {
if (!debug) return;
try {
final prefs = preferences ?? SharedPreferencesAsync();
final stored = await prefs.getString(preferenceKey);
AppConfig.applyRuntimeBaseUrl(
resolve(
stored: stored,
compiled: AppConfig.compiledBaseUrl,
fallback: AppConfig.defaultBaseUrl,
),
);
} catch (_) {
AppConfig.clearRuntimeBaseUrl();
}
}
/// Persists a normalized runtime endpoint.
static Future<bool> save(
String raw, {
SharedPreferencesAsync? preferences,
}) async {
final normalized = normalize(raw);
if (normalized == null) return false;
try {
await (preferences ?? SharedPreferencesAsync()).setString(
preferenceKey,
normalized,
);
return true;
} catch (_) {
return false;
}
}
/// Removes the persisted runtime endpoint.
static Future<void> clear({SharedPreferencesAsync? preferences}) async {
try {
await (preferences ?? SharedPreferencesAsync()).remove(preferenceKey);
} catch (_) {
// Clearing an optional developer override is best effort.
}
}
}
+6
View File
@@ -262,6 +262,12 @@
"freeBonus": "Get Free Bonus",
"watchHistory": "Watch History",
"settings": "Settings",
"serverAddress": "Server address",
"serverAddressHint": "http(s)://host:port",
"saveAddress": "Save address",
"restartToApply": "Restart the app to apply.",
"restoreDefault": "Restore default",
"invalidServerAddress": "Enter a valid http(s) server address.",
"signOut": "Sign Out",
"mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE",
+6
View File
@@ -262,6 +262,12 @@
"freeBonus": "Bonus Gratis",
"watchHistory": "Riwayat Tontonan",
"settings": "Pengaturan",
"serverAddress": "Alamat server",
"serverAddressHint": "http(s)://host:port",
"saveAddress": "Simpan alamat",
"restartToApply": "Mulai ulang aplikasi untuk menerapkan.",
"restoreDefault": "Pulihkan default",
"invalidServerAddress": "Masukkan alamat server http(s) yang valid.",
"signOut": "Keluar",
"mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE",
+36
View File
@@ -1228,6 +1228,42 @@ abstract class AppLocalizations {
/// **'Settings'**
String get settings;
/// No description provided for @serverAddress.
///
/// In en, this message translates to:
/// **'Server address'**
String get serverAddress;
/// No description provided for @serverAddressHint.
///
/// In en, this message translates to:
/// **'http(s)://host:port'**
String get serverAddressHint;
/// No description provided for @saveAddress.
///
/// In en, this message translates to:
/// **'Save address'**
String get saveAddress;
/// No description provided for @restartToApply.
///
/// In en, this message translates to:
/// **'Restart the app to apply.'**
String get restartToApply;
/// No description provided for @restoreDefault.
///
/// In en, this message translates to:
/// **'Restore default'**
String get restoreDefault;
/// No description provided for @invalidServerAddress.
///
/// In en, this message translates to:
/// **'Enter a valid http(s) server address.'**
String get invalidServerAddress;
/// No description provided for @signOut.
///
/// In en, this message translates to:
+18
View File
@@ -601,6 +601,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settings => 'Settings';
@override
String get serverAddress => 'Server address';
@override
String get serverAddressHint => 'http(s)://host:port';
@override
String get saveAddress => 'Save address';
@override
String get restartToApply => 'Restart the app to apply.';
@override
String get restoreDefault => 'Restore default';
@override
String get invalidServerAddress => 'Enter a valid http(s) server address.';
@override
String get signOut => 'Sign Out';
+19
View File
@@ -603,6 +603,25 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get settings => 'Pengaturan';
@override
String get serverAddress => 'Alamat server';
@override
String get serverAddressHint => 'http(s)://host:port';
@override
String get saveAddress => 'Simpan alamat';
@override
String get restartToApply => 'Mulai ulang aplikasi untuk menerapkan.';
@override
String get restoreDefault => 'Pulihkan default';
@override
String get invalidServerAddress =>
'Masukkan alamat server http(s) yang valid.';
@override
String get signOut => 'Keluar';
+18
View File
@@ -592,6 +592,24 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settings => '设置';
@override
String get serverAddress => '服务器地址';
@override
String get serverAddressHint => 'http(s)://主机:端口';
@override
String get saveAddress => '保存地址';
@override
String get restartToApply => '重启 App 生效';
@override
String get restoreDefault => '恢复默认';
@override
String get invalidServerAddress => '请输入有效的 http(s) 服务器地址。';
@override
String get signOut => '退出登录';
+6
View File
@@ -224,6 +224,12 @@
"freeBonus": "领免费奖励",
"watchHistory": "观看历史",
"settings": "设置",
"serverAddress": "服务器地址",
"serverAddressHint": "http(s)://主机:端口",
"saveAddress": "保存地址",
"restartToApply": "重启 App 生效",
"restoreDefault": "恢复默认",
"invalidServerAddress": "请输入有效的 http(s) 服务器地址。",
"signOut": "退出登录",
"mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE",
+3
View File
@@ -2,10 +2,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app.dart';
import 'core/config/server_endpoint.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await ServerEndpoint.load();
runApp(const LumaDramaApp());
}
+162 -19
View File
@@ -1,12 +1,16 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/config/app_config.dart';
import '../../core/config/locale_controller.dart';
import '../../core/config/server_endpoint.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
enum MyPageAction { home, discover, resume }
@@ -110,39 +114,178 @@ class MineLogic extends ChangeNotifier {
}
}
Future<void> showLanguage(BuildContext context) async {
Future<void> showLanguage(
BuildContext context, {
bool showServerSetting = kDebugMode,
}) async {
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF1C1D22),
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(title: Text(l10n.language)),
for (final (code, label) in [
('en', l10n.english),
('zh', l10n.simplifiedChinese),
('id', l10n.bahasaIndonesia),
])
ListTile(
title: Text(label),
onTap: () {
context.read<LocaleController>().select(code);
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.85,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(title: Text(l10n.language)),
for (final (code, label) in [
('en', l10n.english),
('zh', l10n.simplifiedChinese),
('id', l10n.bahasaIndonesia),
])
ListTile(
title: Text(label),
onTap: () {
context.read<LocaleController>().select(code);
Navigator.pop(sheetContext);
},
),
if (showServerSetting) ...[
const Divider(height: 1),
ListTile(
key: const Key('server-endpoint-setting'),
leading: const Icon(Icons.dns_outlined),
title: Text(l10n.serverAddress),
subtitle: Text(AppConfig.apiBaseUrl),
trailing: const Icon(Icons.edit_outlined),
onTap: () => _editServerEndpoint(context, l10n),
),
],
const SizedBox(height: 8),
],
),
),
),
),
);
}
Future<void> _editServerEndpoint(
BuildContext context,
AppLocalizations l10n,
) async {
final changed = await showDialog<bool>(
context: context,
builder: (_) => _ServerEndpointDialog(l10n: l10n),
);
if (changed == true && context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.restartToApply)));
}
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
class _ServerEndpointDialog extends StatefulWidget {
const _ServerEndpointDialog({required this.l10n});
final AppLocalizations l10n;
@override
State<_ServerEndpointDialog> createState() => _ServerEndpointDialogState();
}
class _ServerEndpointDialogState extends State<_ServerEndpointDialog> {
late final TextEditingController _controller = TextEditingController(
text: AppConfig.apiBaseUrl,
);
String? _error;
bool _saving = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _save() async {
final normalized = ServerEndpoint.normalize(_controller.text);
if (normalized == null) {
setState(() => _error = widget.l10n.invalidServerAddress);
return;
}
setState(() {
_saving = true;
_error = null;
});
final saved = await ServerEndpoint.save(normalized);
if (!mounted) return;
if (!saved) {
setState(() {
_saving = false;
_error = widget.l10n.invalidServerAddress;
});
return;
}
Navigator.pop(context, true);
}
Future<void> _restoreDefault() async {
setState(() => _saving = true);
await ServerEndpoint.clear();
if (mounted) Navigator.pop(context, true);
}
@override
Widget build(BuildContext context) {
final l10n = widget.l10n;
return AlertDialog(
title: Text(l10n.serverAddress),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
key: const Key('server-endpoint-input'),
controller: _controller,
enabled: !_saving,
keyboardType: TextInputType.url,
autocorrect: false,
decoration: InputDecoration(
hintText: l10n.serverAddressHint,
errorText: _error,
errorMaxLines: 2,
),
),
const SizedBox(height: 10),
Text(
l10n.restartToApply,
key: const Key('server-endpoint-restart-hint'),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
const SizedBox(height: 4),
TextButton(
key: const Key('server-endpoint-restore'),
onPressed: _saving ? null : _restoreDefault,
child: Text(l10n.restoreDefault),
),
],
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.pop(context, false),
child: Text(l10n.cancel),
),
FilledButton(
key: const Key('server-endpoint-save'),
onPressed: _saving ? null : _save,
child: Text(l10n.saveAddress),
),
],
);
}
}
+11 -2
View File
@@ -2,6 +2,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -41,6 +42,7 @@ class MyPage extends StatefulWidget {
this.onSignOut,
this.dramas,
this.onDramaSelected,
this.debugShowSetting = kDebugMode,
});
final Drama drama;
@@ -57,6 +59,7 @@ class MyPage extends StatefulWidget {
final VoidCallback? onSignOut;
final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected;
final bool debugShowSetting;
@override
State<MyPage> createState() => _MyPageState();
@@ -214,7 +217,10 @@ class _MyPageState extends State<MyPage> {
leading: const Icon(Icons.language_rounded, color: AppColors.blueSoft),
title: Text(l10n.appLanguage),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => logic.showLanguage(context),
onTap: () => logic.showLanguage(
context,
showServerSetting: widget.debugShowSetting,
),
),
];
@@ -471,7 +477,10 @@ class _MyPageState extends State<MyPage> {
),
title: Text(l10n.settings),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => logic.showLanguage(context),
onTap: () => logic.showLanguage(
context,
showServerSetting: widget.debugShowSetting,
),
),
],
),
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/core/config/app_config.dart';
import 'package:fruitshorts/core/config/server_endpoint.dart';
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
AppConfig.clearRuntimeBaseUrl();
});
tearDown(AppConfig.clearRuntimeBaseUrl);
test('should_normalize_http_urls_and_reject_invalid_values', () {
expect(
ServerEndpoint.normalize(' https://192.168.1.9:8081/// '),
'https://192.168.1.9:8081',
);
expect(
ServerEndpoint.normalize('http://example.test:8081/path/'),
'http://example.test:8081/path',
);
expect(ServerEndpoint.normalize('ftp://example.test'), isNull);
expect(ServerEndpoint.normalize('example.test:8081'), isNull);
expect(ServerEndpoint.normalize('http://'), isNull);
});
test('should_prioritize_runtime_over_dart_define_over_default', () {
expect(
ServerEndpoint.resolve(
stored: ' http://runtime.test:8081/ ',
compiled: 'http://define.test:8081',
fallback: 'http://default.test:8081',
),
'http://runtime.test:8081',
);
expect(
ServerEndpoint.resolve(
stored: 'ftp://invalid',
compiled: 'http://define.test:8081/',
fallback: 'http://default.test:8081',
),
'http://define.test:8081',
);
expect(
ServerEndpoint.resolve(
stored: null,
compiled: '',
fallback: 'http://default.test:8081/',
),
'http://default.test:8081',
);
});
test('should_apply_stored_url_only_when_debug_is_enabled', () async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.withData({
ServerEndpoint.preferenceKey: ' http://192.168.31.187:8081/ ',
});
final original = AppConfig.apiBaseUrl;
await ServerEndpoint.load(debug: false);
expect(AppConfig.apiBaseUrl, original);
await ServerEndpoint.load(debug: true);
expect(AppConfig.apiBaseUrl, 'http://192.168.31.187:8081');
});
test('should_save_normalized_url_and_clear_override', () async {
expect(await ServerEndpoint.save('ftp://invalid'), isFalse);
expect(
await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey),
isNull,
);
expect(await ServerEndpoint.save(' http://192.168.31.187:8081/ '), isTrue);
expect(
await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey),
'http://192.168.31.187:8081',
);
await ServerEndpoint.clear();
expect(
await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey),
isNull,
);
});
}
+101
View File
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/core/config/locale_controller.dart';
import 'package:fruitshorts/core/config/server_endpoint.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/mine/mine_logic.dart';
const _drama = Drama(
id: 'drama-1',
title: 'The Signal',
synopsis: 'synopsis',
posterUrl: '/files/1.jpg',
);
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
testWidgets('should_show_server_address_when_debug_entry_is_enabled', (
tester,
) async {
await _pumpSettings(tester, showServerSetting: true);
expect(find.byKey(const Key('server-endpoint-setting')), findsOneWidget);
expect(find.text('Server address'), findsOneWidget);
});
testWidgets('should_hide_server_address_when_debug_entry_is_disabled', (
tester,
) async {
await _pumpSettings(tester, showServerSetting: false);
expect(find.byKey(const Key('server-endpoint-setting')), findsNothing);
expect(find.text('Server address'), findsNothing);
});
testWidgets('should_reject_invalid_server_address_without_saving', (
tester,
) async {
await _pumpSettings(tester, showServerSetting: true);
await tester.tap(find.byKey(const Key('server-endpoint-setting')));
await tester.pumpAndSettle();
expect(find.text('Restart the app to apply.'), findsOneWidget);
await tester.enterText(
find.byKey(const Key('server-endpoint-input')),
'ftp://invalid',
);
await tester.tap(find.byKey(const Key('server-endpoint-save')));
await tester.pumpAndSettle();
expect(find.text('Enter a valid http(s) server address.'), findsOneWidget);
expect(
await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey),
isNull,
);
});
}
Future<void> _pumpSettings(
WidgetTester tester, {
required bool showServerSetting,
}) async {
final logic = MineLogic(
history: InMemoryWatchHistoryRepository(),
drama: _drama,
initialSection: 0,
);
addTearDown(logic.dispose);
await tester.pumpWidget(
ChangeNotifierProvider(
create: (_) => LocaleController(),
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
key: const Key('open-settings'),
onPressed: () => logic.showLanguage(
context,
showServerSetting: showServerSetting,
),
child: const Text('Open settings'),
),
),
),
),
),
);
await tester.tap(find.byKey(const Key('open-settings')));
await tester.pumpAndSettle();
}