fix(sidebar): prevent crash when GitHub URL cannot be launched #71
- Replace bare throw Exception with try/catch + SnackBar in GitHubRepoCard._launchUrl - Use LaunchMode.externalApplication so iOS opens URLs in Safari instead of SFSafariViewController (which can fail on iPadOS under certain conditions) - Store repo URL as static final Uri to avoid repeated parsing - Harden _openPrivacyPolicy in settings and _openHuggingFace in model manager with matching try/catch guards - Add plugin_platform_interface and url_launcher_platform_interface as dev deps for testability - Add widget tests for GitHubRepoCard and regression group in issue_crash_fixes_test.dart
abdulmominsakib committed
Aug 28, 2026 at 18:29 UTC
07188ebbce7116a3518b38431e2633d5db0644d6
7 files changed
+169
-22
lib/features/on_device/views/model_manager_screen.dart
+23
-11
@@ -944,18 +944,30 @@ class _HuggingFaceGgufImportDialogState
944
945
Future<void> _openHuggingFace(BuildContext context) async {
946
final messenger = ScaffoldMessenger.of(context);
947
- final launched = await launchUrl(
948
- _huggingFaceModelsUri,
949
- mode: LaunchMode.externalApplication,
950
- );
951
- if (!launched && context.mounted) {
952
- messenger.showSnackBar(
953
- SnackBar(
954
- content: Text(
955
- AppLocalizations.of(context)!.could_not_open_huggingface,
956
- ),
957
- ),
947
+ try {
948
+ final launched = await launchUrl(
949
+ _huggingFaceModelsUri,
950
+ mode: LaunchMode.externalApplication,
951
);
952
+ if (!launched && context.mounted) {
953
+ messenger.showSnackBar(
954
+ SnackBar(
955
+ content: Text(
956
+ AppLocalizations.of(context)!.could_not_open_huggingface,
957
+ ),
958
+ ),
959
+ );
960
+ }
961
+ } catch (_) {
962
+ if (context.mounted) {
963
+ messenger.showSnackBar(
964
+ SnackBar(
965
+ content: Text(
966
+ AppLocalizations.of(context)!.could_not_open_huggingface,
967
+ ),
968
+ ),
969
+ );
970
+ }
971
}
972
}
973
lib/features/settings/views/settings_screen.dart
+33
-2
@@ -2526,6 +2526,38 @@ class _PrivacyPolicyLink extends StatelessWidget {
2526
'https://momin.pro/privacy-policy-localmind/',
2527
);
2528
2529
+ Future<void> _openPrivacyPolicy(BuildContext context) async {
2530
+ final messenger = ScaffoldMessenger.maybeOf(context);
2531
+ final l10n = AppLocalizations.of(context);
2532
+ try {
2533
+ final ok = await launchUrl(
2534
+ _privacyUrl,
2535
+ mode: LaunchMode.externalApplication,
2536
+ );
2537
+ if (!ok && messenger != null) {
2538
+ messenger.showSnackBar(
2539
+ SnackBar(
2540
+ content: Text(
2541
+ l10n?.failed_to_open_url(_privacyUrl.toString()) ??
2542
+ 'Failed to open URL: $_privacyUrl',
2543
+ ),
2544
+ ),
2545
+ );
2546
+ }
2547
+ } catch (e) {
2548
+ if (messenger != null) {
2549
+ messenger.showSnackBar(
2550
+ SnackBar(
2551
+ content: Text(
2552
+ l10n?.failed_to_open_url(e.toString()) ??
2553
+ 'Failed to open URL: $e',
2554
+ ),
2555
+ ),
2556
+ );
2557
+ }
2558
+ }
2559
+ }
2560
+
2561
@override
2562
Widget build(BuildContext context) {
2563
final l10n = AppLocalizations.of(context)!;
@@ -2534,8 +2566,7 @@ class _PrivacyPolicyLink extends StatelessWidget {
2566
2567
return Center(
2568
child: InkWell(
2537
- onTap: () =>
2538
- launchUrl(_privacyUrl, mode: LaunchMode.externalApplication),
2569
+ onTap: () => _openPrivacyPolicy(context),
2570
borderRadius: BorderRadius.circular(8),
2571
child: Padding(
2572
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
lib/features/sidebar/components/github_repo_card.dart
+31
-6
@@ -8,12 +8,37 @@ import '../../../l10n/app_localizations.dart';
8
class GitHubRepoCard extends StatelessWidget {
9
const GitHubRepoCard({super.key});
10
11
- final String repoUrl = 'https://github.com/abdulmominsakib/localmind';
11
+ static final Uri _repoUri = Uri.parse(
12
+ 'https://github.com/abdulmominsakib/localmind',
13
+ );
14
13
- Future<void> _launchUrl() async {
14
- final Uri url = Uri.parse(repoUrl);
15
- if (!await launchUrl(url)) {
16
- throw Exception('Could not launch $url');
15
+ Future<void> _launchUrl(BuildContext context) async {
16
+ final messenger = ScaffoldMessenger.maybeOf(context);
17
+ final l10n = AppLocalizations.of(context);
18
+ try {
19
+ final launched = await launchUrl(
20
+ _repoUri,
21
+ mode: LaunchMode.externalApplication,
22
+ );
23
+ if (!launched && messenger != null) {
24
+ messenger.showSnackBar(
25
+ SnackBar(
26
+ content: Text(
27
+ l10n?.could_not_open_github ?? 'Could not open GitHub.',
28
+ ),
29
+ ),
30
+ );
31
+ }
32
+ } catch (_) {
33
+ if (messenger != null) {
34
+ messenger.showSnackBar(
35
+ SnackBar(
36
+ content: Text(
37
+ l10n?.could_not_open_github ?? 'Could not open GitHub.',
38
+ ),
39
+ ),
40
+ );
41
+ }
42
}
43
}
44
@@ -33,7 +58,7 @@ class GitHubRepoCard extends StatelessWidget {
58
borderRadius: BorderRadius.circular(8),
59
),
60
child: InkWell(
36
- onTap: _launchUrl,
61
+ onTap: () => _launchUrl(context),
62
borderRadius: BorderRadius.circular(8),
63
child: Padding(
64
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
pubspec.lock
+2
-2
@@ -1430,7 +1430,7 @@ packages:
1430
source: hosted
1431
version: "3.1.6"
1432
plugin_platform_interface:
1433
- dependency: transitive
1433
+ dependency: "direct dev"
1434
description:
1435
name: plugin_platform_interface
1436
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
@@ -2004,7 +2004,7 @@ packages:
2004
source: hosted
2005
version: "3.2.5"
2006
url_launcher_platform_interface:
2007
- dependency: transitive
2007
+ dependency: "direct dev"
2008
description:
2009
name: url_launcher_platform_interface
2010
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
pubspec.yaml
+2
@@ -93,6 +93,8 @@ dependencies:
93
dev_dependencies:
94
flutter_test:
95
sdk: flutter
96
+ plugin_platform_interface: ^2.1.8
97
+ url_launcher_platform_interface: ^2.3.2
98
99
# The "flutter_lints" package below contains a set of recommended lints to
100
# encourage good coding practices. The lint set provided by the package is
test/features/issue_crash_fixes_test.dart
+32
-1
@@ -1,6 +1,5 @@
1
import 'dart:async';
2
import 'dart:io';
3
-import 'dart:typed_data';
3
4
import 'package:dio/dio.dart';
5
import 'package:flutter/material.dart';
@@ -23,8 +22,11 @@ import 'package:localmind/features/servers/data/server_api_service.dart';
22
import 'package:localmind/features/servers/providers/server_providers.dart';
23
import 'package:localmind/features/onboarding/screens/onboarding_server_setup_screen.dart';
24
import 'package:localmind/features/servers/views/add_server_screen.dart';
25
+import 'package:flutter_localizations/flutter_localizations.dart';
26
import 'package:localmind/features/settings/data/models/app_settings.dart';
27
+import 'package:localmind/features/sidebar/components/github_repo_card.dart';
28
import 'package:localmind/l10n/app_localizations.dart';
29
+import 'package:flutter/services.dart';
30
import 'package:shadcn_ui/shadcn_ui.dart';
31
import 'package:shared_preferences/shared_preferences.dart';
32
@@ -760,6 +762,35 @@ void main() {
762
},
763
);
764
});
765
+
766
+ group('Issue #71: GitHub URL Launcher Safe Failure Handling', () {
767
+ testWidgets(
768
+ 'GitHubRepoCard does not throw an unhandled exception when tapped',
769
+ (tester) async {
770
+ // In the test environment there is no native URL launcher. The
771
+ // try/catch in _launchUrl must swallow any failure so the app
772
+ // does not crash — that is the regression guard for issue #71.
773
+ await tester.pumpWidget(
774
+ const MaterialApp(
775
+ localizationsDelegates: [
776
+ AppLocalizations.delegate,
777
+ GlobalMaterialLocalizations.delegate,
778
+ GlobalWidgetsLocalizations.delegate,
779
+ GlobalCupertinoLocalizations.delegate,
780
+ ],
781
+ supportedLocales: AppLocalizations.supportedLocales,
782
+ home: Scaffold(body: Center(child: GitHubRepoCard())),
783
+ ),
784
+ );
785
+ await tester.pumpAndSettle();
786
+
787
+ await tester.tap(find.byType(GitHubRepoCard));
788
+ await tester.pumpAndSettle();
789
+
790
+ expect(tester.takeException(), isNull);
791
+ },
792
+ );
793
+ });
794
}
795
796
class _MutableActiveServerNotifier extends ActiveServerNotifier {
test/features/sidebar/github_repo_card_test.dart
new
+46
@@ -0,0 +1,46 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_localizations/flutter_localizations.dart';
3
+import 'package:flutter_test/flutter_test.dart';
4
+import 'package:localmind/features/sidebar/components/github_repo_card.dart';
5
+import 'package:localmind/l10n/app_localizations.dart';
6
+
7
+void main() {
8
+ TestWidgetsFlutterBinding.ensureInitialized();
9
+
10
+ Widget createWidgetUnderTest() {
11
+ return const MaterialApp(
12
+ localizationsDelegates: [
13
+ AppLocalizations.delegate,
14
+ GlobalMaterialLocalizations.delegate,
15
+ GlobalWidgetsLocalizations.delegate,
16
+ GlobalCupertinoLocalizations.delegate,
17
+ ],
18
+ supportedLocales: AppLocalizations.supportedLocales,
19
+ home: Scaffold(body: Center(child: GitHubRepoCard())),
20
+ );
21
+ }
22
+
23
+ testWidgets('GitHubRepoCard renders title and star prompt', (tester) async {
24
+ await tester.pumpWidget(createWidgetUnderTest());
25
+ await tester.pumpAndSettle();
26
+
27
+ expect(find.text('Open Source'), findsOneWidget);
28
+ expect(find.text('Star on GitHub'), findsOneWidget);
29
+ });
30
+
31
+ testWidgets(
32
+ 'GitHubRepoCard does not throw an unhandled exception when tapped',
33
+ (tester) async {
34
+ // In the test environment there is no native URL launcher. The try/catch
35
+ // in _launchUrl must swallow any failure so the app does not crash.
36
+ await tester.pumpWidget(createWidgetUnderTest());
37
+ await tester.pumpAndSettle();
38
+
39
+ await tester.tap(find.byType(GitHubRepoCard));
40
+ await tester.pumpAndSettle();
41
+
42
+ // The core regression: no unhandled exception must reach the framework.
43
+ expect(tester.takeException(), isNull);
44
+ },
45
+ );
46
+}