diff --git a/client/ios/Podfile.lock b/client/ios/Podfile.lock index 058045f..8a7bbe8 100644 --- a/client/ios/Podfile.lock +++ b/client/ios/Podfile.lock @@ -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 diff --git a/client/lib/screens/contact_page.dart b/client/lib/screens/contact_page.dart index c84e539..ed22d99 100644 --- a/client/lib/screens/contact_page.dart +++ b/client/lib/screens/contact_page.dart @@ -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), ]), - ); + )); } } diff --git a/client/lib/services/channel_launch.dart b/client/lib/services/channel_launch.dart new file mode 100644 index 0000000..62dd3cf --- /dev/null +++ b/client/lib/services/channel_launch.dart @@ -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 launchChannel(String sub) async { + final uri = Uri.parse(channelUrl(sub)); + try { + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } catch (_) {} +} diff --git a/client/lib/widgets/account_screens.dart b/client/lib/widgets/account_screens.dart index 87b4a70..8836d2d 100644 --- a/client/lib/widgets/account_screens.dart +++ b/client/lib/widgets/account_screens.dart @@ -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 { 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), diff --git a/client/test/widget/account_desktop_nav_test.dart b/client/test/widget/account_desktop_nav_test.dart new file mode 100644 index 0000000..4028d04 --- /dev/null +++ b/client/test/widget/account_desktop_nav_test.dart @@ -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 saveTokens({required String access, required String refresh}) async {} + @override + Future loadAccessToken() async => null; + @override + Future loadRefreshToken() async => null; + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +/// 在桌面态渲染 DesktopShell(起始 account 视图),点账户卡里 [label] 那一行, +/// 返回点击后的 navViewProvider 值。 +Future _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); + }); +}