custom pwa and shortcut support

This commit is contained in:
Fabian Freund
2026-03-26 10:38:36 +01:00
parent b0c9d6c283
commit fe07b182ec
30 changed files with 896 additions and 69 deletions
@@ -22,9 +22,10 @@ import 'package:fast_equatable/fast_equatable.dart';
class ReceivedIntentParameter with FastEquatable {
final String? content;
final String? tool;
final String? contextId;
ReceivedIntentParameter(this.content, this.tool);
ReceivedIntentParameter(this.content, this.tool, {this.contextId});
@override
List<Object?> get hashParameters => [content, tool];
List<Object?> get hashParameters => [content, tool, contextId];
}
+2
View File
@@ -30,6 +30,8 @@ extension UriX on Uri {
bool get isHttps => isScheme('https');
bool get isHttpOrHttps => isHttp || isHttps;
bool get isLocalhost => host == 'localhost' || host == '127.0.0.1';
/// Removes a bare root path (`/`) when there is no query or fragment, so
/// that `https://example.com/` and `https://example.com` are treated as
/// equivalent.
@@ -36,7 +36,10 @@ final _contentParserTransformer =
StreamTransformer<ReceivedIntentParameter, SharedContent>.fromHandlers(
handleData: (parameter, sink) {
final parsed = parameter.content.mapNotNull(
(content) => SharedContent.parse(content),
(content) => SharedContent.parse(
content,
contextId: parameter.contextId,
),
);
if (parsed != null) {
@@ -681,8 +681,10 @@ class _AddToHomeScreenTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isInstallable = ref.watch(isCurrentTabInstallableProvider);
final isShortcutable = ref.watch(isCurrentTabShortcutableProvider);
if (!isInstallable) return const SizedBox.shrink();
// Show for installable PWAs or any HTTPS page
if (!isInstallable && !isShortcutable) return const SizedBox.shrink();
return Column(
children: [
@@ -691,7 +693,13 @@ class _AddToHomeScreenTile extends ConsumerWidget {
leading: const Icon(Icons.add_to_home_screen),
title: const Text('Add to Home Screen'),
onTap: () async {
await showPwaInstallDialog(context, ref);
if (isInstallable) {
// Site has valid manifest — use existing PWA install flow
await showPwaInstallDialog(context, ref);
} else {
// No manifest — show shortcut choice dialog
await showShortcutInstallDialog(context, ref);
}
if (context.mounted) Navigator.pop(context);
},
),
@@ -32,6 +32,7 @@ import 'package:weblibre/core/providers/router.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/browser_extension.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
@@ -355,6 +356,12 @@ class _BrowserViewState extends ConsumerState<BrowserView>
final router = await ref.read(routerProvider.future);
final settings = ref.read(generalSettingsWithDefaultsProvider);
// Resolve container from shortcut intent context ID
final containerSelection = await _resolveContainerSelection(
ref,
sharedContent.contextId,
);
switch (settings.tabIntentOpenSetting) {
case TabIntentOpenSetting.regular:
case TabIntentOpenSetting.private:
@@ -371,6 +378,7 @@ class _BrowserViewState extends ConsumerState<BrowserView>
: TabMode.regular,
launchedFromIntent: true,
selectTab: true,
containerSelection: containerSelection,
);
case SharedText():
final bang =
@@ -642,3 +650,22 @@ class _BrowserViewState extends ConsumerState<BrowserView>
super.dispose();
}
}
/// Resolves a [TabContainerSelection] from a shortcut intent's context ID.
/// Returns [TabContainerSelection.useSelected] if no contextId or container not found.
Future<TabContainerSelection> _resolveContainerSelection(
WidgetRef ref,
String? contextId,
) async {
if (contextId == null) return const TabContainerSelection.useSelected();
final container = await ref
.read(containerRepositoryProvider.notifier)
.getContainerByContextualIdentity(contextId);
if (container != null) {
return TabContainerSelection.specific(container);
}
return const TabContainerSelection.useSelected();
}
@@ -211,15 +211,20 @@ class TabMenu extends HookConsumerWidget {
Consumer(
builder: (context, ref, child) {
final isInstallable = ref.watch(isCurrentTabInstallableProvider);
final isShortcutable = ref.watch(isCurrentTabShortcutableProvider);
return Visibility(
visible: isInstallable,
visible: isInstallable || isShortcutable,
child: MenuItemButton(
closeOnActivate: false,
leadingIcon: const Icon(Icons.add_to_home_screen),
child: const Text('Add to Home Screen'),
onPressed: () async {
await showPwaInstallDialog(context, ref);
if (isInstallable) {
await showPwaInstallDialog(context, ref);
} else {
await showShortcutInstallDialog(context, ref);
}
if (context.mounted) {
MenuController.maybeOf(context)?.close();
@@ -24,8 +24,10 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/filesystem.dart' show filesystem;
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/pwa_installability.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
@@ -121,3 +123,46 @@ Future<bool> installCurrentWebApp(Ref ref) async {
Future<List<PwaManifest>> installedWebApps(Ref ref) {
return GeckoPwaApi().getInstalledWebApps();
}
/// Whether the current tab is on an HTTPS page (eligible for home screen shortcut).
@Riverpod()
bool isCurrentTabShortcutable(Ref ref) {
final selectedTabId = ref.watch(selectedTabProvider);
if (selectedTabId == null) return false;
final tabState = ref.watch(tabStateProvider(selectedTabId));
if (tabState == null) return false;
return tabState.url.isHttps ||
(tabState.url.isHttp && tabState.url.isLocalhost);
}
/// Creates a basic bookmark shortcut on the home screen for the current tab.
@Riverpod()
Future<bool> installBasicShortcut(Ref ref, {String? overrideName}) async {
final selectedTabId = ref.read(selectedTabProvider);
if (selectedTabId == null) {
throw StateError('No tab selected');
}
final profileUuid = filesystem.selectedProfile.uuid;
final selectedContainerId = ref.read(selectedContainerProvider);
String? contextId;
if (selectedContainerId != null) {
final containerRepository = ref.read(containerRepositoryProvider.notifier);
final containerData = await containerRepository.getContainerData(
selectedContainerId,
);
contextId = containerData?.metadata.contextualIdentity;
}
return GeckoPwaApi().installBasicShortcut(
selectedTabId,
profileUuid,
contextId,
overrideName,
);
}
@@ -296,3 +296,129 @@ final class InstalledWebAppsProvider
}
String _$installedWebAppsHash() => r'ff185620ccd25bf6b71415e34e3e0c0f20d5e59d';
/// Whether the current tab is on an HTTPS page (eligible for home screen shortcut).
@ProviderFor(isCurrentTabShortcutable)
final isCurrentTabShortcutableProvider = IsCurrentTabShortcutableProvider._();
/// Whether the current tab is on an HTTPS page (eligible for home screen shortcut).
final class IsCurrentTabShortcutableProvider
extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
/// Whether the current tab is on an HTTPS page (eligible for home screen shortcut).
IsCurrentTabShortcutableProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'isCurrentTabShortcutableProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$isCurrentTabShortcutableHash();
@$internal
@override
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
bool create(Ref ref) {
return isCurrentTabShortcutable(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$isCurrentTabShortcutableHash() =>
r'a18fa837facc92397dacb79f38bdefd303ff5d00';
/// Creates a basic bookmark shortcut on the home screen for the current tab.
@ProviderFor(installBasicShortcut)
final installBasicShortcutProvider = InstallBasicShortcutFamily._();
/// Creates a basic bookmark shortcut on the home screen for the current tab.
final class InstallBasicShortcutProvider
extends $FunctionalProvider<AsyncValue<bool>, bool, FutureOr<bool>>
with $FutureModifier<bool>, $FutureProvider<bool> {
/// Creates a basic bookmark shortcut on the home screen for the current tab.
InstallBasicShortcutProvider._({
required InstallBasicShortcutFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'installBasicShortcutProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$installBasicShortcutHash();
@override
String toString() {
return r'installBasicShortcutProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<bool> create(Ref ref) {
final argument = this.argument as String?;
return installBasicShortcut(ref, overrideName: argument);
}
@override
bool operator ==(Object other) {
return other is InstallBasicShortcutProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$installBasicShortcutHash() =>
r'aa8a96e94eac19e3e0b2a087bc6dde12d28f48f2';
/// Creates a basic bookmark shortcut on the home screen for the current tab.
final class InstallBasicShortcutFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<bool>, String?> {
InstallBasicShortcutFamily._()
: super(
retry: null,
name: r'installBasicShortcutProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Creates a basic bookmark shortcut on the home screen for the current tab.
InstallBasicShortcutProvider call({String? overrideName}) =>
InstallBasicShortcutProvider._(argument: overrideName, from: this);
@override
String toString() => r'installBasicShortcutProvider';
}
@@ -19,6 +19,8 @@
*/
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/extensions/uri.dart';
/// Display modes that are valid for installable PWAs per W3C spec.
const _validDisplayModes = {
@@ -40,11 +42,16 @@ const _validDisplayModes = {
/// 3. start_url is within the scope
/// 4. prefer_related_applications is not true
bool isManifestInstallable(PwaManifest manifest) {
final currentUrl = Uri.tryParse(manifest.currentUrl);
final startUrl = Uri.tryParse(manifest.startUrl);
if (currentUrl == null) {
return false;
}
// Check HTTPS requirement (relaxed for localhost)
final isSecure =
manifest.currentUrl.startsWith('https://') ||
manifest.currentUrl.startsWith('http://localhost') ||
manifest.currentUrl.startsWith('http://127.0.0.1');
currentUrl.isHttps || (currentUrl.isHttp && currentUrl.isLocalhost);
if (!isSecure) {
return false;
@@ -62,24 +69,23 @@ bool isManifestInstallable(PwaManifest manifest) {
// W3C §1.10.6: start_url must be same-origin as the document URL
final hasValidStartUrl =
manifest.startUrl.isNotEmpty &&
_isSameOrigin(manifest.startUrl, manifest.currentUrl);
startUrl != null && _isSameOrigin(startUrl, currentUrl);
final hasValidDisplay =
manifest.display != null &&
_validDisplayModes.contains(manifest.display!.toLowerCase());
// Check that start_url is within scope
final isInScope = _isStartUrlInScope(manifest.startUrl, manifest.scope);
final isInScope =
hasValidStartUrl &&
_isStartUrlInScope(startUrl, manifest.scope.mapNotNull(Uri.tryParse));
return hasValidName && hasValidStartUrl && hasValidDisplay && isInScope;
}
/// Returns true if two URLs share the same origin (scheme + host + port).
bool _isSameOrigin(String url1, String url2) {
bool _isSameOrigin(Uri uri1, Uri uri2) {
try {
final uri1 = Uri.parse(url1);
final uri2 = Uri.parse(url2);
return uri1.scheme == uri2.scheme &&
uri1.host == uri2.host &&
uri1.port == uri2.port;
@@ -93,18 +99,14 @@ bool _isSameOrigin(String url1, String url2) {
/// Per W3C spec: when scope is absent, the default scope is the start_url
/// with its last path segment, query, and fragment removed.
/// Scope matching uses path-prefix comparison on `/` boundaries.
bool _isStartUrlInScope(String startUrl, String? scope) {
bool _isStartUrlInScope(Uri startUri, Uri? scopeUri) {
try {
final startUri = Uri.parse(startUrl);
if (scope == null || scope.isEmpty) {
if (scopeUri == null) {
// Per W3C: default scope = start_url with filename/query/fragment removed
// The start_url is trivially within its own default scope.
return true;
}
final scopeUri = Uri.parse(scope);
// Must be same origin
if (startUri.scheme != scopeUri.scheme ||
startUri.host != scopeUri.host ||
@@ -18,26 +18,141 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
/// Shows a dialog asking the user to confirm adding a PWA to the home screen.
/// The type of home screen shortcut the user chose.
enum ShortcutInstallType { shortcut, app }
/// Shows a bottom sheet to confirm adding a PWA to the home screen.
///
/// Returns true if the user confirms, false if cancelled.
Future<bool?> showPwaInstallConfirmDialog(BuildContext context, String name) {
return showDialog<bool>(
/// Returns true if the user confirms, null if dismissed.
Future<bool?> showPwaInstallBottomSheet(
BuildContext context, {
required String name,
required Uri url,
}) {
return showModalBottomSheet<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add to Home Screen'),
content: Text('Add "$name" to your home screen?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Add'),
),
],
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ShortcutSheetHeader(name: name, url: url),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.install_mobile),
title: const Text('Install as App'),
subtitle: const Text('Runs standalone with its own window.'),
onTap: () => Navigator.of(context).pop(true),
),
],
),
),
);
}
/// Shows a bottom sheet for non-manifest sites offering a choice between
/// "Add Shortcut" (opens in browser) and "Install as App" (standalone mode).
///
/// [showAppOption] controls whether the "Install as App" option is visible
/// (requires the allowNonManifestPwaInstall setting to be enabled).
///
/// Returns [ShortcutInstallType] or null if dismissed.
Future<ShortcutInstallType?> showShortcutChoiceBottomSheet(
BuildContext context, {
required String name,
required Uri url,
required bool showAppOption,
}) {
return showModalBottomSheet<ShortcutInstallType>(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ShortcutSheetHeader(name: name, url: url),
const Divider(height: 1),
if (showAppOption)
ListTile(
leading: const Icon(Icons.install_mobile),
title: const Text('Install as App'),
subtitle: const Text('Runs standalone with its own window.'),
onTap: () =>
Navigator.of(context).pop(ShortcutInstallType.app),
),
ListTile(
leading: const Icon(Icons.shortcut),
title: const Text('Add Shortcut'),
subtitle: const Text('Opens as a standard tab in the browser.'),
onTap: () =>
Navigator.of(context).pop(ShortcutInstallType.shortcut),
),
],
),
),
);
}
class _ShortcutSheetHeader extends StatelessWidget {
final String name;
final Uri url;
const _ShortcutSheetHeader({required this.name, required this.url});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Add to Home Screen', style: textTheme.titleMedium),
const SizedBox(height: 12),
Row(
children: [
RepaintBoundary(child: UrlIcon([url], iconSize: 32)),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
UriBreadcrumb(
uri: url,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
],
),
);
}
}
@@ -21,15 +21,28 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/pwa/presentation/dialogs/pwa_install_dialog.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/utils/ui_helper.dart';
/// Shows install bottom sheet for sites with a valid PWA manifest (existing flow).
Future<void> showPwaInstallDialog(BuildContext context, WidgetRef ref) async {
final selectedTabId = ref.read(selectedTabProvider);
final manifest = ref.read(currentTabManifestProvider);
final name = manifest?.name ?? manifest?.shortName ?? 'this web app';
final tabState = selectedTabId != null
? ref.read(tabStateProvider(selectedTabId))
: null;
final url = tabState?.url ?? Uri.parse('about:blank');
final confirmed = await showPwaInstallConfirmDialog(context, name);
final confirmed = await showPwaInstallBottomSheet(
context,
name: name,
url: url,
);
if (confirmed == true) {
try {
@@ -60,3 +73,59 @@ Future<void> showPwaInstallDialog(BuildContext context, WidgetRef ref) async {
}
}
}
/// Shows choice dialog for sites without a manifest.
/// Offers "Add as Shortcut" (always) and "Add as App" (if setting enabled).
Future<void> showShortcutInstallDialog(
BuildContext context,
WidgetRef ref,
) async {
final selectedTabId = ref.read(selectedTabProvider);
if (selectedTabId == null) return;
final tabState = ref.read(tabStateProvider(selectedTabId));
final name = tabState?.title ?? 'this site';
final url = tabState?.url ?? Uri.parse('about:blank');
final settings = ref.read(generalSettingsWithDefaultsProvider);
final showAppOption = settings.allowNonManifestPwaInstall;
final choice = await showShortcutChoiceBottomSheet(
context,
name: name,
url: url,
showAppOption: showAppOption,
);
if (choice == null) return;
try {
final bool success;
switch (choice) {
case ShortcutInstallType.shortcut:
success = await ref.read(installBasicShortcutProvider().future);
case ShortcutInstallType.app:
success = await ref.read(installCurrentWebAppProvider.future);
}
if (context.mounted) {
if (success) {
showInfoMessage(context, '$name added to home screen');
} else {
showErrorMessage(context, 'Failed to add $name to home screen');
}
}
} catch (e, stackTrace) {
logger.e('Failed to create shortcut', error: e, stackTrace: stackTrace);
if (context.mounted) {
var errorMessage = 'Failed to add $name to home screen';
if (e is StateError) {
errorMessage = 'No tab selected. Please try again.';
}
showErrorMessage(context, errorMessage);
}
}
}
@@ -196,4 +196,12 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
Selectable<String?> containersToClearOnExit() {
return db.definitionsDrift.containersToClearOnExit();
}
SingleOrNullSelectable<ContainerData> getContainerByContextualIdentity(
String contextId,
) {
return db.definitionsDrift.containerByContextualIdentity(
contextId: contextId,
);
}
}
@@ -335,6 +335,11 @@ allAssignedSites WITH SiteAssignment:
CROSS JOIN json_each(container.metadata, '$.assignedSites')
WHERE value IS NOT NULL;
containerByContextualIdentity:
SELECT * FROM container
WHERE container.metadata ->> '$.contextualIdentity' = :contextId
LIMIT 1;
containersToClearOnExit:
SELECT container.metadata ->> '$.contextualIdentity' AS contextual_identity
FROM container
@@ -2601,6 +2601,16 @@ class DefinitionsDrift extends i9.ModularAccessor {
);
}
i0.Selectable<i1.ContainerData> containerByContextualIdentity({
required String contextId,
}) {
return customSelect(
'SELECT * FROM container WHERE container.metadata ->> \'\$.contextualIdentity\' = ?1 LIMIT 1',
variables: [i0.Variable<String>(contextId)],
readsFrom: {container},
).asyncMap(container.mapFromRow);
}
i0.Selectable<String?> containersToClearOnExit() {
return customSelect(
'SELECT container.metadata ->> \'\$.contextualIdentity\' AS contextual_identity FROM container WHERE json_extract(container.metadata, \'\$.clearDataOnExit\') = 1 AND container.metadata ->> \'\$.contextualIdentity\' IS NOT NULL',
@@ -77,6 +77,14 @@ class ContainerRepository extends _$ContainerRepository {
.getSingleOrNull();
}
Future<ContainerData?> getContainerByContextualIdentity(String contextId) {
return ref
.read(tabDatabaseProvider)
.containerDao
.getContainerByContextualIdentity(contextId)
.getSingleOrNull();
}
Future<List<String>> getContainerTabIds(String? id) {
return ref
.read(tabDatabaseProvider)
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
}
String _$containerRepositoryHash() =>
r'5e46b6d9d3510aeeb70646ede75d71c2db9efb0f';
r'57ee339087a845e54dc79224e25fa0da19568a68';
abstract class _$ContainerRepository extends $Notifier<void> {
void build();
@@ -48,6 +48,7 @@ class BrowsingSettingsScreen extends StatelessWidget {
children: const [
_TabsSection(),
_NavigationSection(),
_HomeScreenSection(),
_ExternalLinksSection(),
],
);
@@ -665,6 +666,49 @@ class _DoubleBackCloseTabTile extends HookConsumerWidget {
}
}
class _HomeScreenSection extends StatelessWidget {
const _HomeScreenSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Home Screen'),
_AllowNonManifestPwaInstallTile(),
],
);
}
}
class _AllowNonManifestPwaInstallTile extends HookConsumerWidget {
const _AllowNonManifestPwaInstallTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final allowNonManifestPwaInstall = ref.watch(
generalSettingsWithDefaultsProvider
.select((s) => s.allowNonManifestPwaInstall),
);
return SwitchListTile.adaptive(
title: const Text('Install Sites as Apps'),
subtitle: const Text(
'Allow installing websites without a PWA manifest as standalone apps',
),
secondary: const Icon(Icons.add_to_home_screen),
value: allowNonManifestPwaInstall,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.allowNonManifestPwaInstall(value),
);
},
);
}
}
class _UrlCleanerSettingsTile extends StatelessWidget {
const _UrlCleanerSettingsTile();
@@ -21,37 +21,42 @@ import 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/utils/input_classification.dart';
sealed class SharedContent with FastEquatable {
SharedContent();
final String? contextId;
factory SharedContent.parse(String content) {
SharedContent({this.contextId});
factory SharedContent.parse(String content, {String? contextId}) {
if (parseSharedIntentUrl(content) case final Uri uri) {
return SharedUrl(uri);
return SharedUrl(uri, contextId: contextId);
} else {
return SharedText(content);
return SharedText(content, contextId: contextId);
}
}
@override
List<Object?> get hashParameters => [contextId];
}
final class SharedUrl extends SharedContent {
final Uri url;
SharedUrl(this.url);
SharedUrl(this.url, {super.contextId});
@override
String toString() => url.toString();
@override
List<Object?> get hashParameters => [url];
List<Object?> get hashParameters => [url, ...super.hashParameters];
}
final class SharedText extends SharedContent {
final String text;
SharedText(this.text);
SharedText(this.text, {super.contextId});
@override
String toString() => text;
@override
List<Object?> get hashParameters => [text];
List<Object?> get hashParameters => [text, ...super.hashParameters];
}
@@ -45,6 +45,12 @@ final _sharingIntentTransformer =
_ => null,
};
// Extract container context from shortcut intents
final contextId =
intent.action == 'android.intent.action.VIEW'
? intent.extra['pwa_context_id'] as String?
: null;
if (data != null) {
if (uri_to_file.isUriSupported(data)) {
var path = data;
@@ -70,17 +76,23 @@ final _sharingIntentTransformer =
final mimeType = mime.lookupMimeType(file.path);
switch (mimeType) {
case 'application/pdf':
sink.add(ReceivedIntentParameter(path, null));
sink.add(
ReceivedIntentParameter(path, null, contextId: contextId),
);
default:
logger.w('Unhandled mime type: $mimeType');
}
} catch (e) {
logger.e('Failed to convert URI to file: $e');
// Fallback: pass the original URI
sink.add(ReceivedIntentParameter(data, null));
sink.add(
ReceivedIntentParameter(data, null, contextId: contextId),
);
}
} else {
sink.add(ReceivedIntentParameter(data, null));
sink.add(
ReceivedIntentParameter(data, null, contextId: contextId),
);
}
}
},
@@ -118,6 +118,7 @@ class GeneralSettings with FastEquatable {
final bool tabBarLongPressUrlCopy;
final bool unshortenerEnabled;
final String unshortenerToken;
final bool allowNonManifestPwaInstall;
GeneralSettings({
required this.themeMode,
@@ -168,6 +169,7 @@ class GeneralSettings with FastEquatable {
required this.tabBarLongPressUrlCopy,
required this.unshortenerEnabled,
required this.unshortenerToken,
required this.allowNonManifestPwaInstall,
});
GeneralSettings.withDefaults({
@@ -219,6 +221,7 @@ class GeneralSettings with FastEquatable {
bool? tabBarLongPressUrlCopy,
bool? unshortenerEnabled,
String? unshortenerToken,
bool? allowNonManifestPwaInstall,
}) : themeMode = themeMode ?? ThemeMode.dark,
uiScaleFactor = uiScaleFactor ?? defaultUiScaleFactor,
disableAnimations = disableAnimations ?? false,
@@ -276,7 +279,8 @@ class GeneralSettings with FastEquatable {
smallWebTabType = smallWebTabType ?? TabType.private,
tabBarLongPressUrlCopy = tabBarLongPressUrlCopy ?? true,
unshortenerEnabled = unshortenerEnabled ?? false,
unshortenerToken = unshortenerToken ?? '';
unshortenerToken = unshortenerToken ?? '',
allowNonManifestPwaInstall = allowNonManifestPwaInstall ?? false;
factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
_$GeneralSettingsFromJson(json);
@@ -348,5 +352,6 @@ class GeneralSettings with FastEquatable {
tabBarLongPressUrlCopy,
unshortenerEnabled,
unshortenerToken,
allowNonManifestPwaInstall,
];
}
@@ -121,6 +121,8 @@ abstract class _$GeneralSettingsCWProxy {
GeneralSettings unshortenerToken(String unshortenerToken);
GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
///
@@ -177,6 +179,7 @@ abstract class _$GeneralSettingsCWProxy {
bool tabBarLongPressUrlCopy,
bool unshortenerEnabled,
String unshortenerToken,
bool allowNonManifestPwaInstall,
});
}
@@ -391,6 +394,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
GeneralSettings unshortenerToken(String unshortenerToken) =>
call(unshortenerToken: unshortenerToken);
@override
GeneralSettings allowNonManifestPwaInstall(bool allowNonManifestPwaInstall) =>
call(allowNonManifestPwaInstall: allowNonManifestPwaInstall);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
@@ -449,6 +456,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? tabBarLongPressUrlCopy = const $CopyWithPlaceholder(),
Object? unshortenerEnabled = const $CopyWithPlaceholder(),
Object? unshortenerToken = const $CopyWithPlaceholder(),
Object? allowNonManifestPwaInstall = const $CopyWithPlaceholder(),
}) {
return GeneralSettings(
themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null
@@ -733,6 +741,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.unshortenerToken
// ignore: cast_nullable_to_non_nullable
: unshortenerToken as String,
allowNonManifestPwaInstall:
allowNonManifestPwaInstall == const $CopyWithPlaceholder() ||
allowNonManifestPwaInstall == null
? _value.allowNonManifestPwaInstall
// ignore: cast_nullable_to_non_nullable
: allowNonManifestPwaInstall as bool,
);
}
}
@@ -843,6 +857,7 @@ GeneralSettings _$GeneralSettingsFromJson(
tabBarLongPressUrlCopy: json['tabBarLongPressUrlCopy'] as bool?,
unshortenerEnabled: json['unshortenerEnabled'] as bool?,
unshortenerToken: json['unshortenerToken'] as String?,
allowNonManifestPwaInstall: json['allowNonManifestPwaInstall'] as bool?,
);
Map<String, dynamic> _$GeneralSettingsToJson(
@@ -907,6 +922,7 @@ Map<String, dynamic> _$GeneralSettingsToJson(
'tabBarLongPressUrlCopy': instance.tabBarLongPressUrlCopy,
'unshortenerEnabled': instance.unshortenerEnabled,
'unshortenerToken': instance.unshortenerToken,
'allowNonManifestPwaInstall': instance.allowNonManifestPwaInstall,
};
const _$ThemeModeEnumMap = {
@@ -229,6 +229,8 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
DriftSqlType.string,
db.typeMapping,
),
'allowNonManifestPwaInstall': settings['allowNonManifestPwaInstall']
?.readAs(DriftSqlType.bool, db.typeMapping),
});
}
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
}
String _$generalSettingsRepositoryHash() =>
r'ae071b136a14d1f907635579f134608023d81766';
r'afc63f4d929ea146f0b8a7c0f6936b06c5a41024';
abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> {