From b3af20d89add66dc592067e4d9528173928d8b2e Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Thu, 1 Jan 2026 14:02:08 -0600 Subject: [PATCH] support Twitch channel links (#473) --- android/app/src/main/AndroidManifest.xml | 9 +++ lib/main.dart | 96 +++++++++++++++++++++++- lib/widgets/alert_message.dart | 17 +++++ pubspec.lock | 40 ++++++++++ pubspec.yaml | 1 + 5 files changed, 161 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index efaf4819..d6b12e72 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,6 +36,15 @@ + + + + + + + + + { + final AppLinks _appLinks = AppLinks(); + StreamSubscription? _linkSubscription; + @override void initState() { super.initState(); @@ -120,6 +128,8 @@ class _MyAppState extends State { .setMinLaunchTimes(5) .setMinSecondsBeforeShowDialog(3) .monitor(); + + _initDeepLinks(); } @override @@ -127,8 +137,8 @@ class _MyAppState extends State { return Observer( builder: (context) { final settingsStore = context.read(); - final themes = - FrostyThemes(colorSchemeSeed: Color(settingsStore.accentColor)); + final themes = + FrostyThemes(colorSchemeSeed: Color(settingsStore.accentColor)); return Provider( create: (_) => themes, @@ -148,4 +158,86 @@ class _MyAppState extends State { }, ); } + + Future _initDeepLinks() async { + try { + // Handle links when app is already open + _linkSubscription = _appLinks.uriLinkStream.listen((uri) { + handleDeepLink(uri); + }); + + // Handle the initial link if app was opened from a link + final initialLink = await _appLinks.getInitialLink(); + if (initialLink != null) { + handleDeepLink(initialLink); + } + } catch (e) { + debugPrint('Failed to initialize deep links: $e'); + } + } + + Future handleDeepLink(Uri uri) async { + final failureSnackbar = SnackBar( + content: AlertMessage( + message: 'Unable to navigate to \'$uri\'', + centered: false, + trailingIcon: Icons.open_in_browser_rounded, + // Fallback, allow user to open URL outside app + onTrailingIconPressed: () async { + await launchUrl( + uri, + mode: LaunchMode.inAppWebView, // Force browser + ); + }, + ), + ); + + // Handle channel links + if (uri.pathSegments.isNotEmpty) { + final channelName = uri.pathSegments.first; + + try { + final twitchApi = context.read(); + final authStore = context.read(); + + final user = await twitchApi.getUser( + userLogin: channelName, + headers: authStore.headersTwitch, + ); + + final route = MaterialPageRoute( + builder: (context) => VideoChat( + userId: user.id, + userName: user.displayName, + userLogin: user.login, + ), + ); + + if (navigatorKey.currentState == null) return; + + WidgetsBinding.instance.addPostFrameCallback( + (_) => navigatorKey.currentState?.push(route), + ); + } catch (e) { + // If we get here, there was most likely an error with the Twitch API call and/or this isn't really a channel link + debugPrint('Failed to open link $uri due to error: $e'); + + if (navigatorKey.currentContext == null) return; + ScaffoldMessenger.of(navigatorKey.currentContext!) + .showSnackBar(failureSnackbar); + } + } + // TODO: Here we can implement handlers for other types of links + else { + // If we get here, it's a link format that we're unable to handle + if (navigatorKey.currentContext == null) return; + ScaffoldMessenger.of(navigatorKey.currentContext!).showSnackBar(failureSnackbar); + } + } + + @override + void dispose() { + _linkSubscription?.cancel(); + super.dispose(); + } } diff --git a/lib/widgets/alert_message.dart b/lib/widgets/alert_message.dart index 2e51960e..b917141a 100644 --- a/lib/widgets/alert_message.dart +++ b/lib/widgets/alert_message.dart @@ -5,12 +5,16 @@ class AlertMessage extends StatelessWidget { final String message; final Color? color; final bool centered; + final IconData? trailingIcon; + final VoidCallback? onTrailingIconPressed; const AlertMessage({ super.key, required this.message, this.centered = true, this.color, + this.trailingIcon, + this.onTrailingIconPressed, }); @override @@ -35,6 +39,19 @@ class AlertMessage extends StatelessWidget { ), ), ), + if (trailingIcon != null) ...[ + const SizedBox(width: 8), + IconButton( + icon: Icon( + trailingIcon, + color: color ?? defaultColor, + ), + onPressed: onTrailingIconPressed, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + iconSize: 20, + ), + ], ], ); } diff --git a/pubspec.lock b/pubspec.lock index 2edbe0db..c82676a6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: @@ -544,6 +576,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" html: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 730d67c9..5f8e78a8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,6 +68,7 @@ dependencies: flutter_markdown: ^0.7.3+1 flutter_colorpicker: ^1.1.0 path_provider: ^2.1.5 + app_links: ^6.4.1 dev_dependencies: flutter_test: