/* * Copyright (c) 2024-2026 Fabian Freund. * * This file is part of WebLibre * (see https://weblibre.eu). * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import 'package:flutter/material.dart'; import 'package:nullability/nullability.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/utils/clipboard.dart'; /// Creates a floating snackbar. /// The margin is controlled by the scaffold's snackBarTheme for proper /// positioning above bottom app bars of varying heights. SnackBar _createFloatingSnackBar({ required Widget content, Color? backgroundColor, SnackBarAction? action, required Duration duration, required bool persist, }) { return SnackBar( content: content, backgroundColor: backgroundColor, action: action, duration: duration, persist: persist, behavior: SnackBarBehavior.floating, ); } void showErrorMessage( BuildContext context, String message, { Duration duration = const Duration(seconds: 4), bool persist = false, }) { final snackBar = _createFloatingSnackBar( content: Text( message, style: TextStyle(color: Theme.of(context).colorScheme.error), ), backgroundColor: Theme.of(context).colorScheme.onError, duration: duration, persist: persist, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); } void showInfoMessage( BuildContext context, String message, { Duration duration = const Duration(seconds: 4), bool persist = false, }) { final snackBar = _createFloatingSnackBar( content: Text(message), duration: duration, persist: persist, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); } void showOpenedTabsFromAnotherDeviceMessage( BuildContext context, int openedTabs, { Duration duration = const Duration(seconds: 4), bool persist = false, }) { if (openedTabs <= 0) { return; } final message = openedTabs == 1 ? 'Opened 1 tab received from another device' : 'Opened $openedTabs tabs received from another device'; showInfoMessage(context, message, duration: duration, persist: persist); } void showTabBackButtonMessage( BuildContext context, int tabCount, Duration duration, { bool persist = false, }) { final snackbar = _createFloatingSnackBar( content: (tabCount > 1) ? const Text('Navigate BACK again to close current tab') : const Text('Navigate BACK again to exit app'), duration: duration, persist: persist, ); ScaffoldMessenger.of(context) ..clearSnackBars() ..showSnackBar(snackbar); } void showTabOpenedMessage( BuildContext context, { String? tabName, void Function()? onShow, Duration duration = const Duration(seconds: 3), bool persist = false, }) { final message = switch (tabName.whenNotEmpty) { String() => "New tab '$tabName' opened in background", null => 'New tab opened in background', }; final snackBar = _createFloatingSnackBar( content: Text(message), action: onShow.mapNotNull( (onPressed) => SnackBarAction(label: 'Show', onPressed: onPressed), ), duration: duration, persist: persist, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); } Future showSuggestNewTabMessage( BuildContext context, { required void Function(String? searchText) onAdd, Duration duration = const Duration(seconds: 3), bool persist = false, }) async { final clipboardUrl = await tryGetUriFromClipboard(); if (clipboardUrl != null) { final snackBar = _createFloatingSnackBar( content: const Text('Want to open link from clipboard?'), action: SnackBarAction( label: 'Open', onPressed: () { onAdd(clipboardUrl.toString()); }, ), duration: duration, persist: persist, ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(snackBar); } } } void showTabSwitchMessage( BuildContext context, { String? tabName, void Function()? onSwitch, Duration duration = const Duration(seconds: 3), bool persist = false, }) { ScaffoldMessenger.of(context).clearSnackBars(); final message = switch (tabName.whenNotEmpty) { String() => "New tab '$tabName' opened", null => 'New tab opened', }; final snackBar = _createFloatingSnackBar( content: Text(message), action: onSwitch.mapNotNull( (onPressed) => SnackBarAction(label: 'Switch', onPressed: onPressed), ), duration: duration, persist: persist, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); } Future launchUrlFeedback( BuildContext context, Uri url, { LaunchMode mode = LaunchMode.externalApplication, }) async { if (await canLaunchUrl(url)) { try { if (!await launchUrl(url, mode: mode)) { if (context.mounted) { showErrorMessage(context, 'Could not launch URL ($url)'); } } } catch (e, s) { logger.e('Failed to launch URL: $url', error: e, stackTrace: s); if (context.mounted) { showErrorMessage(context, 'Could not launch URL ($url)'); } } } else { if (context.mounted) { showErrorMessage(context, 'Can not handle "${url.scheme}"'); } } } void showTabUndoClose( BuildContext context, VoidCallback onUndo, { int count = 1, Duration duration = const Duration(seconds: 3), bool persist = false, }) { ScaffoldMessenger.of(context).clearSnackBars(); final snackBar = _createFloatingSnackBar( content: (count > 1) ? Text('$count Tabs closed') : const Text('Tab closed'), action: SnackBarAction(label: 'Undo', onPressed: onUndo), duration: duration, persist: persist, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); } /// Shows a confirmation dialog before closing isolated tabs whose data /// will be permanently cleared. Returns `true` if the user confirms. Future confirmIsolatedTabClose( BuildContext context, { int groupCount = 1, }) async { final message = groupCount == 1 ? 'This will permanently clear all browsing data for this isolated session.' : 'This will permanently clear browsing data for $groupCount isolated sessions.'; final result = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Close isolated tabs?'), content: Text(message), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Close'), ), ], ), ); return result ?? false; } void showDismissOverrideMessage( BuildContext context, VoidCallback onDismiss, { Duration duration = const Duration(seconds: 4), bool persist = false, }) { ScaffoldMessenger.of(context).clearSnackBars(); final snackBar = _createFloatingSnackBar( content: const Text('Hiding disabled by site'), action: SnackBarAction(label: 'Dismiss', onPressed: onDismiss), duration: duration, persist: persist, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); }