diff --git a/lib/features/devtools/presentation/dev_tools_screen.dart b/lib/features/devtools/presentation/dev_tools_screen.dart index 281088c..6b58112 100644 --- a/lib/features/devtools/presentation/dev_tools_screen.dart +++ b/lib/features/devtools/presentation/dev_tools_screen.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; -import 'package:poptart_lex/com/atproto/repo/list_records.dart'; import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -10,7 +9,8 @@ import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/core/widgets/app_breadcrumbs.dart'; import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:lazurite/shared/utils/url_utils.dart'; +import 'package:poptart_lex/com/atproto/repo/list_records.dart'; class DevToolsScreen extends StatelessWidget { const DevToolsScreen({super.key, this.initialQuery}); @@ -28,7 +28,7 @@ class DevToolsScreen extends StatelessWidget { IconButton( icon: const Icon(Icons.open_in_new), tooltip: l10n.tooltipGoToPdsLs, - onPressed: () => _openExternalUrl('https://pds.ls'), + onPressed: () => openExternalUrl('https://pds.ls'), ), ], ), @@ -356,7 +356,7 @@ class _EmptyState extends StatelessWidget { ), const SizedBox(height: 16), TextButton.icon( - onPressed: () => _openExternalUrl('https://pds.ls'), + onPressed: () => openExternalUrl('https://pds.ls'), icon: const Icon(Icons.open_in_new, size: 16), label: Text(context.l10n.buttonInspiredByPdsLs), ), @@ -688,7 +688,7 @@ class _RecordInspector extends StatelessWidget { TextButton.icon( icon: const Icon(Icons.open_in_new, size: 16), label: const Text('aturi.to'), - onPressed: () => _openExternalUrl(record.atUriToLink), + onPressed: () => openExternalUrl(record.atUriToLink), ), ], ), @@ -748,10 +748,3 @@ String _initialFor(String? value) { return value.substring(0, 1).toUpperCase(); } - -Future _openExternalUrl(String value) async { - final uri = Uri.parse(value); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } -} diff --git a/lib/features/feed/presentation/trending_screen.dart b/lib/features/feed/presentation/trending_screen.dart index 4949161..823571d 100644 --- a/lib/features/feed/presentation/trending_screen.dart +++ b/lib/features/feed/presentation/trending_screen.dart @@ -11,7 +11,7 @@ import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:lazurite/shared/utils/url_utils.dart'; class TrendingScreen extends StatefulWidget { const TrendingScreen({super.key}); @@ -74,7 +74,7 @@ class _TrendingScreenState extends State { return; } - await launchUrl(resolution.externalUri, mode: LaunchMode.externalApplication); + await openExternalUri(resolution.externalUri); } @override diff --git a/lib/features/feed/presentation/widgets/facet_text.dart b/lib/features/feed/presentation/widgets/facet_text.dart index ee64fe9..8e6da1f 100644 --- a/lib/features/feed/presentation/widgets/facet_text.dart +++ b/lib/features/feed/presentation/widgets/facet_text.dart @@ -1,15 +1,15 @@ import 'dart:convert'; -import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:bluesky_poptart/app/bsky/richtext/facet.dart'; -import 'package:poptart_bluesky_text/poptart_bluesky_text.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/router/in_app_link_resolver.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/search/data/hashtag_utils.dart'; import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:lazurite/shared/utils/url_utils.dart'; +import 'package:poptart_bluesky_text/poptart_bluesky_text.dart'; class FacetText extends StatelessWidget { const FacetText({super.key, required this.text, this.facets, this.style, this.maxLines, this.overflow}); @@ -210,12 +210,7 @@ void _openLink(BuildContext context, String rawLink) { return; } - final uri = Uri.tryParse(rawLink); - if (uri == null) { - return; - } - - _launchExternal(uri); + openExternalUrl(rawLink, addHttpsSchemeWhenMissing: true); } void _openHashtag(BuildContext context, String tag) { @@ -231,7 +226,3 @@ void _openHashtag(BuildContext context, String tag) { router.push('/hashtag?tag=${Uri.encodeQueryComponent(normalizedTag)}'); } - -Future _launchExternal(Uri url) async { - await launchUrl(url, mode: LaunchMode.externalApplication); -} diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 442a8f2..ba05b40 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -51,7 +51,7 @@ import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:lazurite/shared/utils/url_utils.dart'; enum _ProfileFeedSlice { posts, replies, quotes, reposts, media } @@ -792,7 +792,7 @@ class _ProfileScreenState extends State with TickerProviderStateM Icons.link_outlined, profile.website!, trailingIcon: Icons.open_in_new, - onTap: () => _launchWebsite(profile.website!), + onTap: () => openExternalUrl(profile.website!, addHttpsSchemeWhenMissing: true), ), if (profile.createdAt != null) _buildMetaChip( @@ -1505,12 +1505,6 @@ class _ProfileScreenState extends State with TickerProviderStateM return ProfileStarterPacksPane(actor: actor, starterPackRepository: starterPackRepository); } - - Future _launchWebsite(String website) async { - final uri = Uri.tryParse(website.startsWith('http') ? website : 'https://$website'); - if (uri == null) return; - await launchUrl(uri, mode: LaunchMode.externalApplication); - } } class _ProfileReplyThreadItem extends StatelessWidget { diff --git a/lib/features/search/presentation/search_screen.dart b/lib/features/search/presentation/search_screen.dart index 6062d8c..8c68a19 100644 --- a/lib/features/search/presentation/search_screen.dart +++ b/lib/features/search/presentation/search_screen.dart @@ -1,6 +1,5 @@ import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; -import 'package:lazurite/features/moderation/domain/moderation_models.dart' as bsky_moderation; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -13,6 +12,7 @@ import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; import 'package:lazurite/features/feed/presentation/widgets/compact_post_card.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; +import 'package:lazurite/features/moderation/domain/moderation_models.dart' as bsky_moderation; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; import 'package:lazurite/features/search/bloc/search_bloc.dart'; @@ -29,7 +29,7 @@ import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:lazurite/shared/utils/url_utils.dart'; class SearchScreen extends StatefulWidget { const SearchScreen({ @@ -52,7 +52,7 @@ class SearchScreen extends StatefulWidget { } class _SearchScreenState extends State { - static final Uri _starterPackSearchIssueUri = Uri.parse('https://github.com/bluesky-social/bsky-docs/issues/306'); + static const String _starterPackSearchIssueUrl = 'https://github.com/bluesky-social/bsky-docs/issues/306'; final TextEditingController _searchController = TextEditingController(); final FocusNode _focusNode = FocusNode(); @@ -1048,7 +1048,7 @@ class _SearchScreenState extends State { ); Future _openStarterPackIssue() async { - final launched = await launchUrl(_starterPackSearchIssueUri, mode: LaunchMode.externalApplication); + final launched = await openExternalUrl(_starterPackSearchIssueUrl); if (!launched && mounted) { showAppSnackBar(context, context.l10n.messageCouldNotOpenIssueLink); } diff --git a/lib/features/settings/presentation/about_screen.dart b/lib/features/settings/presentation/about_screen.dart index 4984e74..46e0f42 100644 --- a/lib/features/settings/presentation/about_screen.dart +++ b/lib/features/settings/presentation/about_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:lazurite/core/app/app_version_label.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:lazurite/shared/utils/url_utils.dart'; class AboutScreen extends StatelessWidget { const AboutScreen({super.key}); @@ -11,10 +11,6 @@ class AboutScreen extends StatelessWidget { static const _tangledUrl = 'https://tangled.org/desertthunder.dev/lazurite'; static const _emailUrl = 'mailto:info@stormlightlabs.org'; - Future _launch(String url) async { - await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -42,7 +38,7 @@ class AboutScreen extends StatelessWidget { Text('Lazurite is made at Stormlight Labs, which is just me:', style: theme.textTheme.bodyLarge), const SizedBox(height: 8), GestureDetector( - onTap: () => _launch(_linkedInUrl), + onTap: () => openExternalUrl(_linkedInUrl), child: Text( 'Owais', style: theme.textTheme.bodyLarge?.copyWith( @@ -69,7 +65,7 @@ class AboutScreen extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ _LinkIcon( - onTap: () => _launch(_githubUrl), + onTap: () => openExternalUrl(_githubUrl), child: SvgPicture.asset( 'assets/gh.svg', width: 28, @@ -79,7 +75,7 @@ class AboutScreen extends StatelessWidget { ), const SizedBox(width: 24), _LinkIcon( - onTap: () => _launch(_tangledUrl), + onTap: () => openExternalUrl(_tangledUrl), child: SvgPicture.asset( 'assets/tangled.svg', width: 28, @@ -89,7 +85,7 @@ class AboutScreen extends StatelessWidget { ), const SizedBox(width: 24), _LinkIcon( - onTap: () => _launch(_emailUrl), + onTap: () => openExternalUrl(_emailUrl), child: Icon(Icons.email_outlined, size: 28, color: theme.colorScheme.onSurface), ), ], diff --git a/lib/features/settings/presentation/privacy_policy_screen.dart b/lib/features/settings/presentation/privacy_policy_screen.dart index 4881291..fa21900 100644 --- a/lib/features/settings/presentation/privacy_policy_screen.dart +++ b/lib/features/settings/presentation/privacy_policy_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:lazurite/core/app/app_version_label.dart'; import 'package:lazurite/features/settings/presentation/widgets/contact_section.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:lazurite/shared/utils/url_utils.dart'; class PrivacyPolicyScreen extends StatelessWidget { const PrivacyPolicyScreen({super.key}); @@ -11,10 +11,6 @@ class PrivacyPolicyScreen extends StatelessWidget { static const _websiteUrl = 'https://stormlightlabs.org'; static const _emailUrl = 'mailto:info@stormlightlabs.org'; - Future _launch(String url) async { - await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -106,7 +102,10 @@ class PrivacyPolicyScreen extends StatelessWidget { 'We may revise this policy from time to time. Material updates will be reflected by a new effective date and app release notes when appropriate.', ], ), - ContactSection(onStormlightLabsTap: () => _launch(_websiteUrl), onEmailTap: () => _launch(_emailUrl)), + ContactSection( + onStormlightLabsTap: () => openExternalUrl(_websiteUrl), + onEmailTap: () => openExternalUrl(_emailUrl), + ), const SizedBox(height: 12), const Center(child: AppVersionLabel()), ], diff --git a/lib/features/settings/presentation/terms_of_service_screen.dart b/lib/features/settings/presentation/terms_of_service_screen.dart index 77ee089..0ac8ffb 100644 --- a/lib/features/settings/presentation/terms_of_service_screen.dart +++ b/lib/features/settings/presentation/terms_of_service_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:lazurite/core/app/app_version_label.dart'; import 'package:lazurite/features/settings/presentation/widgets/contact_section.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:lazurite/shared/utils/url_utils.dart'; class TermsOfServiceScreen extends StatelessWidget { const TermsOfServiceScreen({super.key}); @@ -13,10 +13,6 @@ class TermsOfServiceScreen extends StatelessWidget { static const _blueskyPrivacyUrl = 'https://bsky.social/about/support/privacy-policy'; static const _blueskyTermsUrl = 'https://bsky.social/about/support/tos'; - Future _launch(String url) async { - await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -81,7 +77,10 @@ class TermsOfServiceScreen extends StatelessWidget { 'Lazurite depends on Bluesky services, and your use of Bluesky is governed by Bluesky\'s own legal terms.', ], ), - _PolicyLinks(onPrivacyTap: () => _launch(_blueskyPrivacyUrl), onTermsTap: () => _launch(_blueskyTermsUrl)), + _PolicyLinks( + onPrivacyTap: () => openExternalUrl(_blueskyPrivacyUrl), + onTermsTap: () => openExternalUrl(_blueskyTermsUrl), + ), const _TermsSection( title: 'Your account and activity', paragraphs: [ @@ -122,7 +121,10 @@ class TermsOfServiceScreen extends StatelessWidget { 'We may update these Terms. Continued use after updates means you accept the revised Terms.', ], ), - ContactSection(onStormlightLabsTap: () => _launch(_websiteUrl), onEmailTap: () => _launch(_emailUrl)), + ContactSection( + onStormlightLabsTap: () => openExternalUrl(_websiteUrl), + onEmailTap: () => openExternalUrl(_emailUrl), + ), const SizedBox(height: 12), const Center(child: AppVersionLabel()), ], diff --git a/lib/shared/presentation/widgets/external_link_preview_card.dart b/lib/shared/presentation/widgets/external_link_preview_card.dart index d42da6c..7786247 100644 --- a/lib/shared/presentation/widgets/external_link_preview_card.dart +++ b/lib/shared/presentation/widgets/external_link_preview_card.dart @@ -4,7 +4,7 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/router/in_app_link_resolver.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:lazurite/shared/utils/url_utils.dart'; class ExternalLinkPreviewCard extends StatelessWidget { const ExternalLinkPreviewCard({ @@ -132,9 +132,5 @@ void _openUri(BuildContext context, String rawUri) { return; } - _launchExternal(uri); -} - -Future _launchExternal(Uri url) async { - await launchUrl(url, mode: LaunchMode.externalApplication); + openExternalUrl(rawUri); } diff --git a/lib/shared/utils/url_utils.dart b/lib/shared/utils/url_utils.dart new file mode 100644 index 0000000..9bc8b4c --- /dev/null +++ b/lib/shared/utils/url_utils.dart @@ -0,0 +1,29 @@ +import 'package:url_launcher/url_launcher.dart'; + +Future openExternalUrl(String value, {bool addHttpsSchemeWhenMissing = false}) async { + final uri = externalUriFor(value, addHttpsSchemeWhenMissing: addHttpsSchemeWhenMissing); + if (uri == null) { + return false; + } + return openExternalUri(uri); +} + +Future openExternalUri(Uri value) => launchUrl(value, mode: LaunchMode.externalApplication); + +Uri? externalUriFor(String value, {bool addHttpsSchemeWhenMissing = false}) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + + final uri = Uri.tryParse(trimmed); + if (uri == null || uri.hasScheme) { + return uri; + } + + if (!addHttpsSchemeWhenMissing) { + return null; + } + + return Uri.tryParse('https://$trimmed'); +} diff --git a/test/shared/utils/url_utils_test.dart b/test/shared/utils/url_utils_test.dart new file mode 100644 index 0000000..e993ae1 --- /dev/null +++ b/test/shared/utils/url_utils_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/shared/utils/url_utils.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +class _FakeUrlLauncher extends Fake with MockPlatformInterfaceMixin implements UrlLauncherPlatform { + _FakeUrlLauncher({this.launchResult = true}); + + final bool launchResult; + final launchedUrls = []; + final launchOptions = []; + final canLaunchCalls = []; + + @override + LinkDelegate? get linkDelegate => null; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launchedUrls.add(url); + launchOptions.add(options); + return launchResult; + } + + @override + Future supportsMode(PreferredLaunchMode mode) async => true; + + @override + Future canLaunch(String url) async { + canLaunchCalls.add(url); + return false; + } +} + +void main() { + group('externalUriFor', () { + test('returns null for blank values', () { + expect(externalUriFor(' '), isNull); + }); + + test('trims values and preserves existing schemes', () { + expect(externalUriFor(' https://example.com/path ')?.toString(), 'https://example.com/path'); + expect(externalUriFor('mailto:info@stormlightlabs.org')?.toString(), 'mailto:info@stormlightlabs.org'); + expect(externalUriFor('tel:+15551234567')?.toString(), 'tel:+15551234567'); + }); + + test('adds https only when requested and no scheme is present', () { + expect(externalUriFor('example.com/path'), isNull); + expect( + externalUriFor('example.com/path', addHttpsSchemeWhenMissing: true)?.toString(), + 'https://example.com/path', + ); + }); + + test('does not force https onto unparsable scheme values', () { + expect(externalUriFor('at://did:plc:test/app.bsky.feed.post/abc', addHttpsSchemeWhenMissing: true), isNull); + }); + }); + + group('openExternalUrl', () { + test('launches with external application mode without preflight canLaunch check', () async { + final fakeLauncher = _FakeUrlLauncher(); + UrlLauncherPlatform.instance = fakeLauncher; + + final launched = await openExternalUrl('https://example.com/article'); + + expect(launched, isTrue); + expect(fakeLauncher.launchedUrls, ['https://example.com/article']); + expect(fakeLauncher.launchOptions.single.mode, PreferredLaunchMode.externalApplication); + expect(fakeLauncher.canLaunchCalls, isEmpty); + }); + + test('returns false and does not launch blank values', () async { + final fakeLauncher = _FakeUrlLauncher(); + UrlLauncherPlatform.instance = fakeLauncher; + + final launched = await openExternalUrl(' '); + + expect(launched, isFalse); + expect(fakeLauncher.launchedUrls, isEmpty); + }); + + test('returns platform launch result', () async { + final fakeLauncher = _FakeUrlLauncher(launchResult: false); + UrlLauncherPlatform.instance = fakeLauncher; + + final launched = await openExternalUrl('https://example.com/article'); + + expect(launched, isFalse); + expect(fakeLauncher.launchedUrls, ['https://example.com/article']); + }); + }); +}