feat: 添加 H5 线路切换功能;支持动态加载和切换不同线路的 URL

This commit is contained in:
Booker
2026-05-27 12:53:47 +07:00
parent e5d3c12c15
commit 8659a2e66e
4 changed files with 472 additions and 10 deletions

View File

@@ -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});