feat: 添加 H5 线路切换功能;支持动态加载和切换不同线路的 URL
This commit is contained in:
16
README.md
16
README.md
@@ -6,6 +6,22 @@ Flutter WebView 套壳 App,默认加载:
|
||||
https://h5-test.imharry.work/
|
||||
```
|
||||
|
||||
## H5 线路切换
|
||||
|
||||
线路切换在 Flutter 套壳层完成,H5 页面不需要承载线路切换逻辑。每条线路都是一个独立 H5 地址,切换时 WebView 会直接加载被选中的地址。
|
||||
|
||||
默认线路配置在:
|
||||
|
||||
```text
|
||||
openim_common/lib/src/config.dart
|
||||
```
|
||||
|
||||
也可以在打包时覆盖:
|
||||
|
||||
```bash
|
||||
flutter build apk --release --dart-define=H5_LINE_URLS=https://h5-one.example/,https://h5-two.example/
|
||||
```
|
||||
|
||||
## 本地打包
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
import 'package:openim_common/openim_common.dart' as openim_common;
|
||||
|
||||
enum Environment {
|
||||
production,
|
||||
}
|
||||
|
||||
class H5Line {
|
||||
const H5Line({
|
||||
required this.label,
|
||||
required this.url,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String url;
|
||||
|
||||
Uri get uri => Uri.parse(url);
|
||||
|
||||
String get displayAddress {
|
||||
final parsed = uri;
|
||||
final host = parsed.hasPort ? '${parsed.host}:${parsed.port}' : parsed.host;
|
||||
final path = parsed.path.isEmpty || parsed.path == '/' ? '' : parsed.path;
|
||||
return '$host$path';
|
||||
}
|
||||
}
|
||||
|
||||
class AppConfig {
|
||||
static const Environment currentEnvironment = Environment.production;
|
||||
static const String appName = '本地打包';
|
||||
static const String appLogo = '';
|
||||
static const String canonicalWebHost = 'h5-test.imharry.work';
|
||||
static const String _dartDefinedH5LineUrls =
|
||||
String.fromEnvironment('H5_LINE_URLS');
|
||||
static const Set<String> legacyWebHosts = {
|
||||
'h5-test.imharry.work',
|
||||
};
|
||||
@@ -18,14 +41,34 @@ class AppConfig {
|
||||
};
|
||||
|
||||
static List<String> get environmentHosts {
|
||||
return _environmentHosts[currentEnvironment] ?? const [];
|
||||
final generatedHosts = _normalizedUniqueUrls(
|
||||
openim_common.Config.environmentHosts,
|
||||
);
|
||||
if (generatedHosts.isNotEmpty) {
|
||||
return generatedHosts;
|
||||
}
|
||||
|
||||
return _normalizedUniqueUrls(_environmentHosts[currentEnvironment] ?? []);
|
||||
}
|
||||
|
||||
static String homeUrl({String? appName, String? appLogo}) {
|
||||
final host =
|
||||
environmentHosts.isNotEmpty ? environmentHosts.first : canonicalWebHost;
|
||||
final uri = Uri.parse(_normalizeHomeUrl(host));
|
||||
return uri.toString();
|
||||
static List<H5Line> get h5Lines {
|
||||
final configuredUrls = _dartDefinedH5LineUrls.trim().isEmpty
|
||||
? environmentHosts
|
||||
: _normalizedUniqueUrls(_dartDefinedH5LineUrls.split(','));
|
||||
final urls = configuredUrls.isEmpty
|
||||
? _normalizedUniqueUrls(const [canonicalWebHost])
|
||||
: configuredUrls;
|
||||
|
||||
return [
|
||||
for (var index = 0; index < urls.length; index += 1)
|
||||
H5Line(label: '线路${index + 1}', url: urls[index]),
|
||||
];
|
||||
}
|
||||
|
||||
static String homeUrl({int lineIndex = 0, String? appName, String? appLogo}) {
|
||||
final lines = h5Lines;
|
||||
final safeIndex = _safeLineIndex(lineIndex, lines.length);
|
||||
return lines[safeIndex].url;
|
||||
}
|
||||
|
||||
static String withFreshShellParams(String url) {
|
||||
@@ -39,13 +82,14 @@ class AppConfig {
|
||||
}
|
||||
|
||||
final host = uri.host.toLowerCase();
|
||||
return legacyWebHosts.contains(host) || _hasShellParams(uri);
|
||||
return (legacyWebHosts.contains(host) && !_isConfiguredH5Host(host)) ||
|
||||
_hasShellParams(uri);
|
||||
}
|
||||
|
||||
static String canonicalizeMainFrameUrl(String url) {
|
||||
final uri = _removeShellParams(Uri.parse(url));
|
||||
final host = uri.host.toLowerCase();
|
||||
if (!legacyWebHosts.contains(host)) {
|
||||
if (!legacyWebHosts.contains(host) || _isConfiguredH5Host(host)) {
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
@@ -57,6 +101,21 @@ class AppConfig {
|
||||
.toString();
|
||||
}
|
||||
|
||||
static int? lineIndexForUrl(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final lines = h5Lines;
|
||||
for (var index = 0; index < lines.length; index += 1) {
|
||||
if (_isSameLine(lines[index].uri, uri)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Uri _removeShellParams(Uri uri) {
|
||||
final queryParameters = Map<String, String>.from(uri.queryParameters);
|
||||
queryParameters.remove('flutter_shell');
|
||||
@@ -85,8 +144,70 @@ class AppConfig {
|
||||
fragmentParameters.containsKey('shell_app_logo');
|
||||
}
|
||||
|
||||
static int _safeLineIndex(int index, int length) {
|
||||
if (length <= 0 || index < 0 || index >= length) {
|
||||
return 0;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
static bool _isConfiguredH5Host(String host) {
|
||||
return h5Lines.any((line) => line.uri.host.toLowerCase() == host);
|
||||
}
|
||||
|
||||
static bool _isSameLine(Uri lineUri, Uri uri) {
|
||||
if (lineUri.host.toLowerCase() != uri.host.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
if (lineUri.hasPort && lineUri.port != uri.port) {
|
||||
return false;
|
||||
}
|
||||
if (lineUri.scheme.isNotEmpty && lineUri.scheme != uri.scheme) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final linePath = _pathWithTrailingSlash(lineUri.path);
|
||||
final uriPath = _pathWithTrailingSlash(uri.path);
|
||||
return linePath == '/' || uriPath.startsWith(linePath);
|
||||
}
|
||||
|
||||
static String _pathWithTrailingSlash(String path) {
|
||||
if (path.isEmpty) {
|
||||
return '/';
|
||||
}
|
||||
return path.endsWith('/') ? path : '$path/';
|
||||
}
|
||||
|
||||
static List<String> _normalizedUniqueUrls(Iterable<String> values) {
|
||||
final urls = <String>[];
|
||||
final seen = <String>{};
|
||||
|
||||
for (final value in values) {
|
||||
final normalized = _tryNormalizeHomeUrl(value);
|
||||
if (normalized == null || seen.contains(normalized)) {
|
||||
continue;
|
||||
}
|
||||
urls.add(normalized);
|
||||
seen.add(normalized);
|
||||
}
|
||||
|
||||
return List.unmodifiable(urls);
|
||||
}
|
||||
|
||||
static String? _tryNormalizeHomeUrl(String value) {
|
||||
try {
|
||||
return _normalizeHomeUrl(value);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String _normalizeHomeUrl(String host) {
|
||||
final value = host.trim();
|
||||
if (value.isEmpty) {
|
||||
throw const FormatException('Empty H5 line URL');
|
||||
}
|
||||
|
||||
final normalized =
|
||||
value.startsWith('http://') || value.startsWith('https://')
|
||||
? value
|
||||
|
||||
293
lib/main.dart
293
lib/main.dart
@@ -94,17 +94,22 @@ class H5ShellPage extends StatefulWidget {
|
||||
|
||||
class _H5ShellPageState extends State<H5ShellPage> {
|
||||
late final WebViewController _controller;
|
||||
late final List<H5Line> _h5Lines;
|
||||
|
||||
int _progress = 0;
|
||||
String? _loadError;
|
||||
bool _showShellCover = true;
|
||||
bool _shellBrandingLoaded = false;
|
||||
int _currentLineIndex = 0;
|
||||
Timer? _shellCoverFallbackTimer;
|
||||
late ShellBranding _shellBranding;
|
||||
|
||||
H5Line get _currentLine => _h5Lines[_currentLineIndex];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_h5Lines = AppConfig.h5Lines;
|
||||
_shellBranding = widget.initialShellBranding;
|
||||
_controller = _buildController();
|
||||
unawaited(_loadHome());
|
||||
@@ -125,10 +130,14 @@ class _H5ShellPageState extends State<H5ShellPage> {
|
||||
setState(() => _progress = progress);
|
||||
}
|
||||
},
|
||||
onPageStarted: (_) {
|
||||
onPageStarted: (url) {
|
||||
_shellCoverFallbackTimer?.cancel();
|
||||
final lineIndex = AppConfig.lineIndexForUrl(url);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (lineIndex != null) {
|
||||
_currentLineIndex = lineIndex;
|
||||
}
|
||||
_loadError = null;
|
||||
_progress = 0;
|
||||
_showShellCover = true;
|
||||
@@ -169,7 +178,7 @@ class _H5ShellPageState extends State<H5ShellPage> {
|
||||
}
|
||||
|
||||
String _freshHomeUrl() {
|
||||
return AppConfig.homeUrl();
|
||||
return AppConfig.homeUrl(lineIndex: _currentLineIndex);
|
||||
}
|
||||
|
||||
Future<void> _handlePageFinished() async {
|
||||
@@ -256,10 +265,14 @@ class _H5ShellPageState extends State<H5ShellPage> {
|
||||
|
||||
Future<void> _loadUrl(String url) async {
|
||||
final targetUrl = AppConfig.canonicalizeMainFrameUrl(url);
|
||||
final lineIndex = AppConfig.lineIndexForUrl(targetUrl);
|
||||
|
||||
if (mounted) {
|
||||
_shellCoverFallbackTimer?.cancel();
|
||||
setState(() {
|
||||
if (lineIndex != null) {
|
||||
_currentLineIndex = lineIndex;
|
||||
}
|
||||
_loadError = null;
|
||||
_progress = 0;
|
||||
_showShellCover = true;
|
||||
@@ -269,6 +282,41 @@ class _H5ShellPageState extends State<H5ShellPage> {
|
||||
await _controller.loadRequest(Uri.parse(targetUrl));
|
||||
}
|
||||
|
||||
Future<void> _loadLine(int index) async {
|
||||
final safeIndex = index < 0 || index >= _h5Lines.length ? 0 : index;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentLineIndex = safeIndex;
|
||||
});
|
||||
}
|
||||
|
||||
await _loadUrl(_h5Lines[safeIndex].url);
|
||||
}
|
||||
|
||||
void _showLineSwitcher() {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return _LineSwitcherSheet(
|
||||
lines: _h5Lines,
|
||||
currentIndex: _currentLineIndex,
|
||||
onSelect: (index) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
if (index != _currentLineIndex) {
|
||||
unawaited(_loadLine(index));
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<NavigationDecision> _handleNavigationRequest(
|
||||
NavigationRequest request,
|
||||
) async {
|
||||
@@ -306,6 +354,10 @@ class _H5ShellPageState extends State<H5ShellPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||
final showLineSwitch =
|
||||
!_showShellCover && _loadError == null && bottomInset == 0;
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
@@ -337,6 +389,16 @@ class _H5ShellPageState extends State<H5ShellPage> {
|
||||
message: _loadError!,
|
||||
onRetry: () => unawaited(_loadHome()),
|
||||
),
|
||||
if (showLineSwitch)
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: MediaQuery.paddingOf(context).bottom + 14,
|
||||
child: _LineSwitchBar(
|
||||
currentLine: _currentLine,
|
||||
onSwitch: _showLineSwitcher,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -386,6 +448,233 @@ class _ErrorPanel extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _LineSwitchBar extends StatelessWidget {
|
||||
const _LineSwitchBar({
|
||||
required this.currentLine,
|
||||
required this.onSwitch,
|
||||
});
|
||||
|
||||
final H5Line currentLine;
|
||||
final VoidCallback onSwitch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Material(
|
||||
color: Colors.white.withValues(alpha: 0.94),
|
||||
elevation: 8,
|
||||
shadowColor: const Color(0xFF0F4C81).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: InkWell(
|
||||
onTap: onSwitch,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Container(
|
||||
height: 64,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
border: Border.all(color: const Color(0xFFE1EAF5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.sync_rounded,
|
||||
size: 19,
|
||||
color: _shellSubText,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'当前: ',
|
||||
style: TextStyle(
|
||||
color: _shellSubText,
|
||||
fontSize: 15,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
currentLine.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF17233D),
|
||||
fontSize: 16,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'切换',
|
||||
style: TextStyle(
|
||||
color: _shellAccent,
|
||||
fontSize: 15,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LineSwitcherSheet extends StatelessWidget {
|
||||
const _LineSwitcherSheet({
|
||||
required this.lines,
|
||||
required this.currentIndex,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final List<H5Line> lines;
|
||||
final int currentIndex;
|
||||
final ValueChanged<int> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sheetHeight = MediaQuery.sizeOf(context).height * 0.58;
|
||||
final maxHeight = sheetHeight > 420 ? 420.0 : sheetHeight;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'切换线路',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF17233D),
|
||||
fontSize: 18,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: lines.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final line = lines[index];
|
||||
final selected = index == currentIndex;
|
||||
|
||||
return _LineOptionTile(
|
||||
line: line,
|
||||
selected: selected,
|
||||
onTap: () => onSelect(index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LineOptionTile extends StatelessWidget {
|
||||
const _LineOptionTile({
|
||||
required this.line,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final H5Line line;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: selected ? const Color(0xFFEFF7FF) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 64),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: selected ? _shellAccent : const Color(0xFFE1EAF5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
selected
|
||||
? Icons.radio_button_checked_rounded
|
||||
: Icons.radio_button_unchecked_rounded,
|
||||
color: selected ? _shellAccent : _shellSubText,
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
line.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF17233D),
|
||||
fontSize: 16,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
line.displayAddress,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: _shellSubText,
|
||||
fontSize: 12,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: _shellSubText,
|
||||
size: 22,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShellFallback extends StatelessWidget {
|
||||
const _ShellFallback({required this.progress});
|
||||
|
||||
|
||||
@@ -30,6 +30,26 @@ void main() {
|
||||
isFalse);
|
||||
});
|
||||
|
||||
test('exposes configured H5 URLs as Flutter shell lines', () {
|
||||
final lines = AppConfig.h5Lines;
|
||||
|
||||
expect(lines, isNotEmpty);
|
||||
expect(lines.first.label, '线路1');
|
||||
expect(lines.first.url, AppConfig.homeUrl(lineIndex: 0));
|
||||
expect(Uri.parse(lines.first.url).host, 'h5-test.imharry.work');
|
||||
});
|
||||
|
||||
test('falls back to the first H5 line for invalid line indexes', () {
|
||||
expect(AppConfig.homeUrl(lineIndex: -1), AppConfig.h5Lines.first.url);
|
||||
expect(AppConfig.homeUrl(lineIndex: 99), AppConfig.h5Lines.first.url);
|
||||
});
|
||||
|
||||
test('matches runtime URLs back to their Flutter shell line', () {
|
||||
final lineUrl = AppConfig.h5Lines.first.url;
|
||||
|
||||
expect(AppConfig.lineIndexForUrl('${lineUrl}login'), 0);
|
||||
});
|
||||
|
||||
test('refreshes an H5 route URL without adding branding to the URL', () {
|
||||
final uri = Uri.parse(
|
||||
AppConfig.withFreshShellParams(
|
||||
@@ -48,6 +68,22 @@ void main() {
|
||||
isFalse);
|
||||
});
|
||||
|
||||
test('keeps independent non-legacy H5 hosts when removing shell params', () {
|
||||
final uri = Uri.parse(
|
||||
AppConfig.canonicalizeMainFrameUrl(
|
||||
'https://line-two.example/login?shell_app_name=Old'
|
||||
'&flutter_shell=1#shell_app_logo=old',
|
||||
),
|
||||
);
|
||||
|
||||
expect(uri.host, 'line-two.example');
|
||||
expect(uri.path, '/login');
|
||||
expect(uri.queryParameters.containsKey('shell_app_name'), isFalse);
|
||||
expect(uri.queryParameters.containsKey('flutter_shell'), isFalse);
|
||||
expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'),
|
||||
isFalse);
|
||||
});
|
||||
|
||||
test('rewrites legacy H5 host main-frame URLs to the canonical host', () {
|
||||
final uri = Uri.parse(
|
||||
AppConfig.canonicalizeMainFrameUrl(
|
||||
|
||||
Reference in New Issue
Block a user