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/
|
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
|
```bash
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
|
import 'package:openim_common/openim_common.dart' as openim_common;
|
||||||
|
|
||||||
enum Environment {
|
enum Environment {
|
||||||
production,
|
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 {
|
class AppConfig {
|
||||||
static const Environment currentEnvironment = Environment.production;
|
static const Environment currentEnvironment = Environment.production;
|
||||||
static const String appName = '本地打包';
|
static const String appName = '本地打包';
|
||||||
static const String appLogo = '';
|
static const String appLogo = '';
|
||||||
static const String canonicalWebHost = 'h5-test.imharry.work';
|
static const String canonicalWebHost = 'h5-test.imharry.work';
|
||||||
|
static const String _dartDefinedH5LineUrls =
|
||||||
|
String.fromEnvironment('H5_LINE_URLS');
|
||||||
static const Set<String> legacyWebHosts = {
|
static const Set<String> legacyWebHosts = {
|
||||||
'h5-test.imharry.work',
|
'h5-test.imharry.work',
|
||||||
};
|
};
|
||||||
@@ -18,14 +41,34 @@ class AppConfig {
|
|||||||
};
|
};
|
||||||
|
|
||||||
static List<String> get environmentHosts {
|
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}) {
|
static List<H5Line> get h5Lines {
|
||||||
final host =
|
final configuredUrls = _dartDefinedH5LineUrls.trim().isEmpty
|
||||||
environmentHosts.isNotEmpty ? environmentHosts.first : canonicalWebHost;
|
? environmentHosts
|
||||||
final uri = Uri.parse(_normalizeHomeUrl(host));
|
: _normalizedUniqueUrls(_dartDefinedH5LineUrls.split(','));
|
||||||
return uri.toString();
|
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) {
|
static String withFreshShellParams(String url) {
|
||||||
@@ -39,13 +82,14 @@ class AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final host = uri.host.toLowerCase();
|
final host = uri.host.toLowerCase();
|
||||||
return legacyWebHosts.contains(host) || _hasShellParams(uri);
|
return (legacyWebHosts.contains(host) && !_isConfiguredH5Host(host)) ||
|
||||||
|
_hasShellParams(uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String canonicalizeMainFrameUrl(String url) {
|
static String canonicalizeMainFrameUrl(String url) {
|
||||||
final uri = _removeShellParams(Uri.parse(url));
|
final uri = _removeShellParams(Uri.parse(url));
|
||||||
final host = uri.host.toLowerCase();
|
final host = uri.host.toLowerCase();
|
||||||
if (!legacyWebHosts.contains(host)) {
|
if (!legacyWebHosts.contains(host) || _isConfiguredH5Host(host)) {
|
||||||
return uri.toString();
|
return uri.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +101,21 @@ class AppConfig {
|
|||||||
.toString();
|
.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) {
|
static Uri _removeShellParams(Uri uri) {
|
||||||
final queryParameters = Map<String, String>.from(uri.queryParameters);
|
final queryParameters = Map<String, String>.from(uri.queryParameters);
|
||||||
queryParameters.remove('flutter_shell');
|
queryParameters.remove('flutter_shell');
|
||||||
@@ -85,8 +144,70 @@ class AppConfig {
|
|||||||
fragmentParameters.containsKey('shell_app_logo');
|
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) {
|
static String _normalizeHomeUrl(String host) {
|
||||||
final value = host.trim();
|
final value = host.trim();
|
||||||
|
if (value.isEmpty) {
|
||||||
|
throw const FormatException('Empty H5 line URL');
|
||||||
|
}
|
||||||
|
|
||||||
final normalized =
|
final normalized =
|
||||||
value.startsWith('http://') || value.startsWith('https://')
|
value.startsWith('http://') || value.startsWith('https://')
|
||||||
? value
|
? value
|
||||||
|
|||||||
293
lib/main.dart
293
lib/main.dart
@@ -94,17 +94,22 @@ class H5ShellPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _H5ShellPageState extends State<H5ShellPage> {
|
class _H5ShellPageState extends State<H5ShellPage> {
|
||||||
late final WebViewController _controller;
|
late final WebViewController _controller;
|
||||||
|
late final List<H5Line> _h5Lines;
|
||||||
|
|
||||||
int _progress = 0;
|
int _progress = 0;
|
||||||
String? _loadError;
|
String? _loadError;
|
||||||
bool _showShellCover = true;
|
bool _showShellCover = true;
|
||||||
bool _shellBrandingLoaded = false;
|
bool _shellBrandingLoaded = false;
|
||||||
|
int _currentLineIndex = 0;
|
||||||
Timer? _shellCoverFallbackTimer;
|
Timer? _shellCoverFallbackTimer;
|
||||||
late ShellBranding _shellBranding;
|
late ShellBranding _shellBranding;
|
||||||
|
|
||||||
|
H5Line get _currentLine => _h5Lines[_currentLineIndex];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_h5Lines = AppConfig.h5Lines;
|
||||||
_shellBranding = widget.initialShellBranding;
|
_shellBranding = widget.initialShellBranding;
|
||||||
_controller = _buildController();
|
_controller = _buildController();
|
||||||
unawaited(_loadHome());
|
unawaited(_loadHome());
|
||||||
@@ -125,10 +130,14 @@ class _H5ShellPageState extends State<H5ShellPage> {
|
|||||||
setState(() => _progress = progress);
|
setState(() => _progress = progress);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPageStarted: (_) {
|
onPageStarted: (url) {
|
||||||
_shellCoverFallbackTimer?.cancel();
|
_shellCoverFallbackTimer?.cancel();
|
||||||
|
final lineIndex = AppConfig.lineIndexForUrl(url);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
if (lineIndex != null) {
|
||||||
|
_currentLineIndex = lineIndex;
|
||||||
|
}
|
||||||
_loadError = null;
|
_loadError = null;
|
||||||
_progress = 0;
|
_progress = 0;
|
||||||
_showShellCover = true;
|
_showShellCover = true;
|
||||||
@@ -169,7 +178,7 @@ class _H5ShellPageState extends State<H5ShellPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _freshHomeUrl() {
|
String _freshHomeUrl() {
|
||||||
return AppConfig.homeUrl();
|
return AppConfig.homeUrl(lineIndex: _currentLineIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handlePageFinished() async {
|
Future<void> _handlePageFinished() async {
|
||||||
@@ -256,10 +265,14 @@ class _H5ShellPageState extends State<H5ShellPage> {
|
|||||||
|
|
||||||
Future<void> _loadUrl(String url) async {
|
Future<void> _loadUrl(String url) async {
|
||||||
final targetUrl = AppConfig.canonicalizeMainFrameUrl(url);
|
final targetUrl = AppConfig.canonicalizeMainFrameUrl(url);
|
||||||
|
final lineIndex = AppConfig.lineIndexForUrl(targetUrl);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_shellCoverFallbackTimer?.cancel();
|
_shellCoverFallbackTimer?.cancel();
|
||||||
setState(() {
|
setState(() {
|
||||||
|
if (lineIndex != null) {
|
||||||
|
_currentLineIndex = lineIndex;
|
||||||
|
}
|
||||||
_loadError = null;
|
_loadError = null;
|
||||||
_progress = 0;
|
_progress = 0;
|
||||||
_showShellCover = true;
|
_showShellCover = true;
|
||||||
@@ -269,6 +282,41 @@ class _H5ShellPageState extends State<H5ShellPage> {
|
|||||||
await _controller.loadRequest(Uri.parse(targetUrl));
|
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(
|
Future<NavigationDecision> _handleNavigationRequest(
|
||||||
NavigationRequest request,
|
NavigationRequest request,
|
||||||
) async {
|
) async {
|
||||||
@@ -306,6 +354,10 @@ class _H5ShellPageState extends State<H5ShellPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||||
|
final showLineSwitch =
|
||||||
|
!_showShellCover && _loadError == null && bottomInset == 0;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: false,
|
canPop: false,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
@@ -337,6 +389,16 @@ class _H5ShellPageState extends State<H5ShellPage> {
|
|||||||
message: _loadError!,
|
message: _loadError!,
|
||||||
onRetry: () => unawaited(_loadHome()),
|
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 {
|
class _ShellFallback extends StatelessWidget {
|
||||||
const _ShellFallback({required this.progress});
|
const _ShellFallback({required this.progress});
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,26 @@ void main() {
|
|||||||
isFalse);
|
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', () {
|
test('refreshes an H5 route URL without adding branding to the URL', () {
|
||||||
final uri = Uri.parse(
|
final uri = Uri.parse(
|
||||||
AppConfig.withFreshShellParams(
|
AppConfig.withFreshShellParams(
|
||||||
@@ -48,6 +68,22 @@ void main() {
|
|||||||
isFalse);
|
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', () {
|
test('rewrites legacy H5 host main-frame URLs to the canonical host', () {
|
||||||
final uri = Uri.parse(
|
final uri = Uri.parse(
|
||||||
AppConfig.canonicalizeMainFrameUrl(
|
AppConfig.canonicalizeMainFrameUrl(
|
||||||
|
|||||||
Reference in New Issue
Block a user