fix(client): 联系/购买渠道卡点击外部打开(修空 onTap)+ 桌面账户子导航复现测试

- channel_launch: @→t.me / 邮箱→mailto / 域名→https,外部打开
- 联系页(桌面)/联系&兑换子页(移动)渠道卡接上 onTap
- account_desktop_nav_test: 证明桌面设置/兑换/联系/订单导航正常(231 全绿)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-12 06:54:18 +08:00
parent b30f472d90
commit c3d32a476e
5 changed files with 141 additions and 4 deletions
+6
View File
@@ -2,20 +2,26 @@ PODS:
- Flutter (1.0.0)
- flutter_secure_storage (6.0.0):
- Flutter
- open_filex (0.0.2):
- Flutter
DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
EXTERNAL SOURCES:
Flutter:
:path: Flutter
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
open_filex:
:path: ".symlinks/plugins/open_filex/ios"
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
PODFILE CHECKSUM: 953f21eefd3c2835779b3cd836901e88e3270439
+5 -2
View File
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../services/channel_launch.dart';
import '../state/app_providers.dart';
import '../widgets/pangolin_icons.dart';
@@ -50,7 +51,9 @@ class _ChannelCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return Container(
return GestureDetector(
onTap: () => launchChannel(channel.sub),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: c.surface,
@@ -77,7 +80,7 @@ class _ChannelCard extends StatelessWidget {
),
Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
]),
);
));
}
}
+21
View File
@@ -0,0 +1,21 @@
// channel_launch.dart — 联系/购买渠道点击:按 handle(sub)推导 URL 并外部打开。
// @xxx → https://t.me/xxx (Telegram)
// a@b.com → mailto:a@b.com
// x.yanmei... → https://x.yanmei...
import 'package:url_launcher/url_launcher.dart';
String channelUrl(String sub) {
if (sub.startsWith('@')) return 'https://t.me/${sub.substring(1)}';
if (sub.contains('@')) return 'mailto:$sub';
return 'https://$sub';
}
/// 点击渠道卡:外部打开对应 URL。失败静默(不炸 UI)。
Future<void> launchChannel(String sub) async {
final uri = Uri.parse(channelUrl(sub));
try {
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
} catch (_) {}
}
+3 -2
View File
@@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../models/device.dart';
import '../pangolin_theme.dart';
import '../services/channel_launch.dart';
import '../screens/settings_page.dart';
import '../services/device_identity.dart';
import 'adaptive_menu.dart';
@@ -429,7 +430,7 @@ class _RedeemScreenState extends ConsumerState<RedeemScreen> {
Widget _channel(PangolinScheme c, IconData icon, String name, String sub, bool accent) {
return InkWell(
borderRadius: BorderRadius.circular(PangolinRadius.lg),
onTap: () {},
onTap: () => launchChannel(sub),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: accent ? c.accentSubtle : c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: accent ? c.accentBorder : c.border), boxShadow: PangolinShadow.sm),
@@ -486,7 +487,7 @@ class ContactScreen extends StatelessWidget {
Widget _contact(PangolinScheme c, IconData icon, String name, String sub, bool accent) {
return InkWell(
borderRadius: BorderRadius.circular(PangolinRadius.lg),
onTap: () {},
onTap: () => launchChannel(sub),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: accent ? c.accentSubtle : c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: accent ? c.accentBorder : c.border), boxShadow: PangolinShadow.sm),
@@ -0,0 +1,106 @@
// account_desktop_nav_test.dart — 复现 Windows/桌面账户页子导航点击是否切 navView。
//
// 用 debugDefaultTargetPlatformOverride=windows + 宽 surface 让 context.formFactor==desktop
// 渲染 DesktopShell 账户页,点每个 _NavRow,断言 navViewProvider 切到对应子视图。
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart';
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
import 'package:pangolin_vpn/l10n/app_text.dart';
import 'package:pangolin_vpn/l10n/strings_zh.dart';
import 'package:pangolin_vpn/pangolin_theme.dart';
import 'package:pangolin_vpn/screens/account_page.dart';
import 'package:pangolin_vpn/services/token_store.dart';
import 'package:pangolin_vpn/shell/desktop_shell.dart';
import 'package:pangolin_vpn/shell/home_shell.dart';
import 'package:pangolin_vpn/state/app_providers.dart';
import 'package:pangolin_vpn/state/auth_provider.dart';
import 'package:pangolin_vpn/state/navigation_provider.dart';
import '../helpers/harness.dart';
final AppText t = StringsZh();
class _NullTokenStore implements TokenStore {
const _NullTokenStore();
@override
Future<void> saveTokens({required String access, required String refresh}) async {}
@override
Future<String?> loadAccessToken() async => null;
@override
Future<String?> loadRefreshToken() async => null;
@override
Future<void> clear() async {}
@override
Future<void> markOnboarded() async {}
@override
Future<bool> isOnboarded() async => true;
@override
Future<void> saveLastEmail(String email) async {}
@override
Future<String?> loadLastEmail() async => null;
}
/// 在桌面态渲染 DesktopShell(起始 account 视图),点账户卡里 [label] 那一行,
/// 返回点击后的 navViewProvider 值。
Future<NavView> _tapRow(WidgetTester tester, String label) async {
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
tester.view.physicalSize = const Size(920, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
try {
await tester.pumpWidget(ProviderScope(
overrides: [
tokenStoreProvider.overrideWithValue(const _NullTokenStore()),
vpnBridgeProvider.overrideWithValue(VpnBridgeMock()),
appTextProvider.overrideWithValue(t),
navViewProvider.overrideWith((ref) => NavView.account),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: PangolinTheme.light,
home: const HomeShell(),
),
));
await tester.pumpAndSettle();
expect(find.byType(DesktopShell), findsOneWidget);
expect(find.byType(AccountPage), findsOneWidget);
// 侧栏与账户卡可能共享同一文案(设置/联系我们),故限定在 AccountPage 内。
final row = find.descendant(of: find.byType(AccountPage), matching: find.text(label));
expect(row, findsOneWidget, reason: '账户卡里应有且仅有一行「$label');
final container = ProviderScope.containerOf(tester.element(find.byType(HomeShell)));
expect(container.read(navViewProvider), NavView.account);
await tester.tap(row);
await tester.pump();
return container.read(navViewProvider);
} finally {
debugDefaultTargetPlatformOverride = null;
}
}
void main() {
setUpAll(disableGoogleFontsFetching);
testWidgets('desktop account -> settings', (tester) async {
expect(await _tapRow(tester, t.settingsTitle), NavView.settings);
});
testWidgets('desktop account -> redeem', (tester) async {
expect(await _tapRow(tester, t.redeemEntry), NavView.redeem);
});
testWidgets('desktop account -> contact', (tester) async {
expect(await _tapRow(tester, t.contactEntry), NavView.contact);
});
testWidgets('desktop account -> orders', (tester) async {
expect(await _tapRow(tester, t.ordersTitle), NavView.orders);
});
testWidgets('desktop account -> devices', (tester) async {
expect(await _tapRow(tester, t.myDevices), NavView.devices);
});
}