pwa improvements: custom name and context selection
This commit is contained in:
@@ -250,14 +250,25 @@ class TabTreeRoute extends GoRouteData with $TabTreeRoute {
|
||||
|
||||
class OpenSharedContentRoute extends GoRouteData with $OpenSharedContentRoute {
|
||||
final String sharedUrl;
|
||||
final String? contextId;
|
||||
final String? containerMode;
|
||||
|
||||
const OpenSharedContentRoute({this.sharedUrl = 'about:blank'});
|
||||
const OpenSharedContentRoute({
|
||||
this.sharedUrl = 'about:blank',
|
||||
this.contextId,
|
||||
this.containerMode,
|
||||
});
|
||||
|
||||
@override
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
return BottomSheetPage(
|
||||
builder: (_) => OpenSharedContent(
|
||||
sharedUrl: Uri.tryParse(sharedUrl) ?? Uri.parse('about:blank'),
|
||||
contextId: contextId,
|
||||
containerMode: IntentContainerMode.fromWireValue(
|
||||
containerMode,
|
||||
contextId: contextId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ import 'package:weblibre/features/settings/presentation/screens/tracking_protect
|
||||
import 'package:weblibre/features/settings/presentation/screens/web_content_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening.dart';
|
||||
import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening_group.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
|
||||
import 'package:weblibre/features/sync/presentation/screens/sync_settings.dart';
|
||||
import 'package:weblibre/features/tor/presentation/screens/country_picker.dart';
|
||||
import 'package:weblibre/features/tor/presentation/screens/tor_proxy.dart';
|
||||
|
||||
@@ -860,6 +860,8 @@ mixin $OpenSharedContentRoute on GoRouteData {
|
||||
static OpenSharedContentRoute _fromState(GoRouterState state) =>
|
||||
OpenSharedContentRoute(
|
||||
sharedUrl: state.uri.queryParameters['shared-url'] ?? 'about:blank',
|
||||
contextId: state.uri.queryParameters['context-id'],
|
||||
containerMode: state.uri.queryParameters['container-mode'],
|
||||
);
|
||||
|
||||
OpenSharedContentRoute get _self => this as OpenSharedContentRoute;
|
||||
@@ -869,6 +871,8 @@ mixin $OpenSharedContentRoute on GoRouteData {
|
||||
'/browser/open_content',
|
||||
queryParams: {
|
||||
if (_self.sharedUrl != 'about:blank') 'shared-url': _self.sharedUrl,
|
||||
if (_self.contextId != null) 'context-id': _self.contextId,
|
||||
if (_self.containerMode != null) 'container-mode': _self.containerMode,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -18,14 +18,21 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
|
||||
|
||||
class ReceivedIntentParameter with FastEquatable {
|
||||
final String? content;
|
||||
final String? tool;
|
||||
final String? contextId;
|
||||
final IntentContainerMode containerMode;
|
||||
|
||||
ReceivedIntentParameter(this.content, this.tool, {this.contextId});
|
||||
ReceivedIntentParameter(
|
||||
this.content,
|
||||
this.tool, {
|
||||
this.contextId,
|
||||
this.containerMode = IntentContainerMode.useSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [content, tool, contextId];
|
||||
List<Object?> get hashParameters => [content, tool, contextId, containerMode];
|
||||
}
|
||||
|
||||
@@ -35,8 +35,11 @@ final _contentParserTransformer =
|
||||
StreamTransformer<ReceivedIntentParameter, SharedContent>.fromHandlers(
|
||||
handleData: (parameter, sink) {
|
||||
final parsed = parameter.content.mapNotNull(
|
||||
(content) =>
|
||||
SharedContent.parse(content, contextId: parameter.contextId),
|
||||
(content) => SharedContent.parse(
|
||||
content,
|
||||
contextId: parameter.contextId,
|
||||
containerMode: parameter.containerMode,
|
||||
),
|
||||
);
|
||||
|
||||
if (parsed != null) {
|
||||
|
||||
+18
-4
@@ -58,6 +58,7 @@ import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_inte
|
||||
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
|
||||
import 'package:weblibre/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart';
|
||||
import 'package:weblibre/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
|
||||
@@ -403,6 +404,7 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
||||
final containerSelection = await _resolveContainerSelection(
|
||||
ref,
|
||||
sharedContent.contextId,
|
||||
sharedContent.containerMode,
|
||||
);
|
||||
|
||||
await ref
|
||||
@@ -443,6 +445,8 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
||||
case SharedUrl():
|
||||
final route = OpenSharedContentRoute(
|
||||
sharedUrl: sharedContent.url.toString(),
|
||||
contextId: sharedContent.contextId,
|
||||
containerMode: sharedContent.containerMode.queryValueOrNull,
|
||||
);
|
||||
await router.push(route.location);
|
||||
case SharedText():
|
||||
@@ -704,13 +708,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a [TabContainerSelection] from a shortcut intent's context ID.
|
||||
/// Returns [TabContainerSelection.useSelected] if no contextId or container not found.
|
||||
/// Resolves a [TabContainerSelection] from incoming launch container metadata.
|
||||
Future<TabContainerSelection> _resolveContainerSelection(
|
||||
WidgetRef ref,
|
||||
String? contextId,
|
||||
IntentContainerMode containerMode,
|
||||
) async {
|
||||
if (contextId == null) return const TabContainerSelection.useSelected();
|
||||
if (contextId == null) {
|
||||
return switch (containerMode) {
|
||||
IntentContainerMode.unassigned =>
|
||||
const TabContainerSelection.unassigned(),
|
||||
_ => const TabContainerSelection.useSelected(),
|
||||
};
|
||||
}
|
||||
|
||||
final container = await ref
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
@@ -720,5 +730,9 @@ Future<TabContainerSelection> _resolveContainerSelection(
|
||||
return TabContainerSelection.specific(container);
|
||||
}
|
||||
|
||||
return const TabContainerSelection.useSelected();
|
||||
return switch (containerMode) {
|
||||
IntentContainerMode.useSelected =>
|
||||
const TabContainerSelection.useSelected(),
|
||||
_ => const TabContainerSelection.unassigned(),
|
||||
};
|
||||
}
|
||||
|
||||
+164
-70
@@ -27,6 +27,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart';
|
||||
@@ -35,22 +36,34 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/presentatio
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/attribution_link.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/url_cleaner_tile.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/entities/container_selection_result.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
import 'package:weblibre/presentation/hooks/debouncer.dart';
|
||||
import 'package:weblibre/presentation/icons/weblibre_icons.dart';
|
||||
import 'package:weblibre/utils/form_validators.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
class OpenSharedContent extends HookConsumerWidget {
|
||||
final Uri sharedUrl;
|
||||
final String? contextId;
|
||||
final IntentContainerMode containerMode;
|
||||
|
||||
static final _appLinksService = GeckoAppLinksService();
|
||||
|
||||
const OpenSharedContent({super.key, required this.sharedUrl});
|
||||
const OpenSharedContent({
|
||||
super.key,
|
||||
required this.sharedUrl,
|
||||
this.contextId,
|
||||
this.containerMode = IntentContainerMode.useSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -58,7 +71,81 @@ class OpenSharedContent extends HookConsumerWidget {
|
||||
final textController = useTextEditingController(text: sharedUrl.toString());
|
||||
final appColors = AppColors.of(context);
|
||||
|
||||
final selectedContainer = useState<ContainerData?>(null);
|
||||
final globalSelectedContainer = ref.watch(
|
||||
selectedContainerDataProvider.select((value) => value.value),
|
||||
);
|
||||
final selectedContainer = useState<ContainerData?>(
|
||||
containerMode == IntentContainerMode.useSelected
|
||||
? globalSelectedContainer
|
||||
: null,
|
||||
);
|
||||
final containerSelectionTouched = useRef(false);
|
||||
final defaultTabType = ref.read(
|
||||
generalSettingsWithDefaultsProvider.select(
|
||||
(value) => value.effectiveDefaultCreateTabType,
|
||||
),
|
||||
);
|
||||
|
||||
// If the intent carried an isolated context, preselect the isolated tab
|
||||
// type and preserve the existing context id so opening doesn't mint a
|
||||
// fresh one.
|
||||
final carriedIsolatedContextId = useMemoized(
|
||||
() => isIsolatedContextId(contextId) ? contextId : null,
|
||||
[contextId],
|
||||
);
|
||||
|
||||
final selectedTabType = useState(
|
||||
carriedIsolatedContextId != null ? TabType.isolated : defaultTabType,
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (!containerSelectionTouched.value &&
|
||||
containerMode == IntentContainerMode.useSelected) {
|
||||
selectedContainer.value = globalSelectedContainer;
|
||||
}
|
||||
return null;
|
||||
}, [globalSelectedContainer, containerMode]);
|
||||
|
||||
useEffect(() {
|
||||
if (containerMode != IntentContainerMode.specific ||
|
||||
contextId == null ||
|
||||
isIsolatedContextId(contextId)) {
|
||||
if (!containerSelectionTouched.value &&
|
||||
containerMode == IntentContainerMode.unassigned) {
|
||||
selectedContainer.value = null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var cancelled = false;
|
||||
|
||||
unawaited(
|
||||
Future(() async {
|
||||
final container = await ref
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.getContainerByContextualIdentity(contextId!);
|
||||
if (cancelled ||
|
||||
!context.mounted ||
|
||||
containerSelectionTouched.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedContainer.value = container;
|
||||
}),
|
||||
);
|
||||
|
||||
return () {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [containerMode, contextId]);
|
||||
|
||||
TabMode resolveTabMode() {
|
||||
if (selectedTabType.value == TabType.isolated &&
|
||||
carriedIsolatedContextId != null) {
|
||||
return TabMode.isolated(carriedIsolatedContextId);
|
||||
}
|
||||
return TabMode.fromTabType(selectedTabType.value);
|
||||
}
|
||||
|
||||
final settings = ref.watch(generalSettingsWithDefaultsProvider);
|
||||
final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider);
|
||||
@@ -151,7 +238,7 @@ class OpenSharedContent extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openCustomTab(bool isPrivate) async {
|
||||
Future<void> openCustomTab(TabMode tabMode) async {
|
||||
if (formKey.currentState?.validate() == true) {
|
||||
final parsedUrl = parseValidatedUrl(
|
||||
textController.text,
|
||||
@@ -161,10 +248,14 @@ class OpenSharedContent extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final contextId = tabMode is IsolatedTabMode
|
||||
? tabMode.isolationContextId
|
||||
: selectedContainer.value?.metadata.contextualIdentity;
|
||||
|
||||
await GeckoBrowserService().openInCustomTab(
|
||||
url: parsedUrl,
|
||||
private: isPrivate,
|
||||
contextId: selectedContainer.value?.id,
|
||||
private: tabMode is PrivateTabMode,
|
||||
contextId: contextId,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
@@ -203,18 +294,7 @@ class OpenSharedContent extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Open link', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
if (settings.showContainerUi)
|
||||
ContainerChips(
|
||||
displayMenu: false,
|
||||
selectedContainer: selectedContainer.value,
|
||||
onSelected: (container) {
|
||||
selectedContainer.value = container;
|
||||
},
|
||||
onDeleted: (container) {
|
||||
selectedContainer.value = null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: textController,
|
||||
keyboardType: TextInputType.url,
|
||||
@@ -337,64 +417,78 @@ class OpenSharedContent extends HookConsumerWidget {
|
||||
icon: Icons.open_in_new,
|
||||
onTap: openInApp,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final tabTypeSwitcher = AnimatedTabTypeSwitcher(
|
||||
selected: selectedTabType.value,
|
||||
onChanged: (value) => selectedTabType.value = value,
|
||||
showIsolatedOption:
|
||||
settings.showIsolatedTabUi ||
|
||||
carriedIsolatedContextId != null,
|
||||
selectedBackgroundColor: switch (selectedTabType.value) {
|
||||
TabType.regular => null,
|
||||
TabType.private => appColors.privateSelectionOverlay,
|
||||
TabType.isolated => appColors.isolatedSelectionOverlay,
|
||||
TabType.child => null,
|
||||
},
|
||||
);
|
||||
|
||||
if (!settings.showContainerUi) {
|
||||
return Center(child: tabTypeSwitcher);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: tabTypeSwitcher,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: CompactContainerSelector(
|
||||
selectedContainer: selectedContainer.value,
|
||||
onSelectionChanged: (selection) async {
|
||||
containerSelectionTouched.value = true;
|
||||
switch (selection) {
|
||||
case ContainerSelectionSelected(
|
||||
:final containerId,
|
||||
):
|
||||
selectedContainer.value = await ref
|
||||
.read(
|
||||
containerRepositoryProvider.notifier,
|
||||
)
|
||||
.getContainerData(containerId);
|
||||
case ContainerSelectionUnassigned():
|
||||
selectedContainer.value = null;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_OpenActionTile(
|
||||
title: 'Open in new tab',
|
||||
subtitle: 'Add to your browser tabs',
|
||||
icon: MdiIcons.tab,
|
||||
trailing: PopupMenuButton<TabMode>(
|
||||
icon: const Icon(WebLibreIcons.tabType, size: 24),
|
||||
tooltip: settings.showIsolatedTabUi
|
||||
? 'Private / Isolated'
|
||||
: 'Private',
|
||||
onSelected: openTab,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: TabMode.private,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
MdiIcons.dominoMask,
|
||||
color: appColors.privateTabPurple,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Private'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (settings.showIsolatedTabUi)
|
||||
PopupMenuItem(
|
||||
value: TabMode.newIsolated(),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
MdiIcons.snowflake,
|
||||
color: appColors.isolatedTabTeal,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Isolated'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => openTab(TabMode.regular),
|
||||
onTap: () => openTab(resolveTabMode()),
|
||||
),
|
||||
_OpenActionTile(
|
||||
title: 'Open in custom tab',
|
||||
subtitle: 'Open in a separate window',
|
||||
icon: MdiIcons.applicationOutline,
|
||||
trailing: IconButton(
|
||||
icon: Icon(
|
||||
MdiIcons.dominoMask,
|
||||
color: appColors.privateTabPurple,
|
||||
size: 24,
|
||||
),
|
||||
tooltip: 'Private',
|
||||
onPressed: () => openCustomTab(true),
|
||||
),
|
||||
onTap: () => openCustomTab(false),
|
||||
onTap: () => openCustomTab(resolveTabMode()),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -491,7 +585,7 @@ class _OpenActionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Widget? trailing;
|
||||
final bool showTrailingDivider;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _OpenActionTile({
|
||||
required this.title,
|
||||
@@ -499,7 +593,7 @@ class _OpenActionTile extends StatelessWidget {
|
||||
required this.icon,
|
||||
this.trailing,
|
||||
this.showTrailingDivider = true,
|
||||
required this.onTap,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
|
||||
@@ -29,8 +29,6 @@ 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';
|
||||
|
||||
part 'providers.g.dart';
|
||||
|
||||
@@ -95,7 +93,11 @@ bool isCurrentTabInstallable(Ref ref) {
|
||||
/// Installs the current tab as a PWA, embedding profile and container context
|
||||
/// in the shortcut intent so the PWA reopens with the same isolation.
|
||||
@Riverpod()
|
||||
Future<bool> installCurrentWebApp(Ref ref) async {
|
||||
Future<bool> installCurrentWebApp(
|
||||
Ref ref, {
|
||||
String? overrideName,
|
||||
String? contextId,
|
||||
}) async {
|
||||
final selectedTabId = ref.read(selectedTabProvider);
|
||||
|
||||
if (selectedTabId == null) {
|
||||
@@ -104,18 +106,12 @@ Future<bool> installCurrentWebApp(Ref ref) async {
|
||||
|
||||
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().installWebApp(selectedTabId, profileUuid, contextId);
|
||||
return GeckoPwaApi().installWebApp(
|
||||
selectedTabId,
|
||||
profileUuid,
|
||||
contextId,
|
||||
overrideName,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns all installed PWAs.
|
||||
@@ -139,7 +135,11 @@ bool isCurrentTabShortcutable(Ref ref) {
|
||||
|
||||
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||
@Riverpod()
|
||||
Future<bool> installBasicShortcut(Ref ref, {String? overrideName}) async {
|
||||
Future<bool> installBasicShortcut(
|
||||
Ref ref, {
|
||||
String? overrideName,
|
||||
String? contextId,
|
||||
}) async {
|
||||
final selectedTabId = ref.read(selectedTabProvider);
|
||||
|
||||
if (selectedTabId == null) {
|
||||
@@ -148,17 +148,6 @@ Future<bool> installBasicShortcut(Ref ref, {String? overrideName}) async {
|
||||
|
||||
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,
|
||||
|
||||
@@ -213,7 +213,7 @@ String _$isCurrentTabInstallableHash() =>
|
||||
/// in the shortcut intent so the PWA reopens with the same isolation.
|
||||
|
||||
@ProviderFor(installCurrentWebApp)
|
||||
final installCurrentWebAppProvider = InstallCurrentWebAppProvider._();
|
||||
final installCurrentWebAppProvider = InstallCurrentWebAppFamily._();
|
||||
|
||||
/// Installs the current tab as a PWA, embedding profile and container context
|
||||
/// in the shortcut intent so the PWA reopens with the same isolation.
|
||||
@@ -223,20 +223,27 @@ final class InstallCurrentWebAppProvider
|
||||
with $FutureModifier<bool>, $FutureProvider<bool> {
|
||||
/// Installs the current tab as a PWA, embedding profile and container context
|
||||
/// in the shortcut intent so the PWA reopens with the same isolation.
|
||||
InstallCurrentWebAppProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'installCurrentWebAppProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
InstallCurrentWebAppProvider._({
|
||||
required InstallCurrentWebAppFamily super.from,
|
||||
required ({String? overrideName, String? contextId}) super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'installCurrentWebAppProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$installCurrentWebAppHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'installCurrentWebAppProvider'
|
||||
''
|
||||
'$argument';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
@@ -244,12 +251,61 @@ final class InstallCurrentWebAppProvider
|
||||
|
||||
@override
|
||||
FutureOr<bool> create(Ref ref) {
|
||||
return installCurrentWebApp(ref);
|
||||
final argument =
|
||||
this.argument as ({String? overrideName, String? contextId});
|
||||
return installCurrentWebApp(
|
||||
ref,
|
||||
overrideName: argument.overrideName,
|
||||
contextId: argument.contextId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is InstallCurrentWebAppProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$installCurrentWebAppHash() =>
|
||||
r'da536e2aca886831e0bbbc5b70eae03ac4a4ea9f';
|
||||
r'0e0013fac7b70441983f7af1e6db3c6470c23900';
|
||||
|
||||
/// Installs the current tab as a PWA, embedding profile and container context
|
||||
/// in the shortcut intent so the PWA reopens with the same isolation.
|
||||
|
||||
final class InstallCurrentWebAppFamily extends $Family
|
||||
with
|
||||
$FunctionalFamilyOverride<
|
||||
FutureOr<bool>,
|
||||
({String? overrideName, String? contextId})
|
||||
> {
|
||||
InstallCurrentWebAppFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'installCurrentWebAppProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Installs the current tab as a PWA, embedding profile and container context
|
||||
/// in the shortcut intent so the PWA reopens with the same isolation.
|
||||
|
||||
InstallCurrentWebAppProvider call({
|
||||
String? overrideName,
|
||||
String? contextId,
|
||||
}) => InstallCurrentWebAppProvider._(
|
||||
argument: (overrideName: overrideName, contextId: contextId),
|
||||
from: this,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => r'installCurrentWebAppProvider';
|
||||
}
|
||||
|
||||
/// Returns all installed PWAs.
|
||||
|
||||
@@ -357,7 +413,7 @@ final class InstallBasicShortcutProvider
|
||||
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||
InstallBasicShortcutProvider._({
|
||||
required InstallBasicShortcutFamily super.from,
|
||||
required String? super.argument,
|
||||
required ({String? overrideName, String? contextId}) super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'installBasicShortcutProvider',
|
||||
@@ -373,7 +429,7 @@ final class InstallBasicShortcutProvider
|
||||
String toString() {
|
||||
return r'installBasicShortcutProvider'
|
||||
''
|
||||
'($argument)';
|
||||
'$argument';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@@ -383,8 +439,13 @@ final class InstallBasicShortcutProvider
|
||||
|
||||
@override
|
||||
FutureOr<bool> create(Ref ref) {
|
||||
final argument = this.argument as String?;
|
||||
return installBasicShortcut(ref, overrideName: argument);
|
||||
final argument =
|
||||
this.argument as ({String? overrideName, String? contextId});
|
||||
return installBasicShortcut(
|
||||
ref,
|
||||
overrideName: argument.overrideName,
|
||||
contextId: argument.contextId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -399,12 +460,16 @@ final class InstallBasicShortcutProvider
|
||||
}
|
||||
|
||||
String _$installBasicShortcutHash() =>
|
||||
r'aa8a96e94eac19e3e0b2a087bc6dde12d28f48f2';
|
||||
r'fcb5bec79f375b32a7a0953ad22859a0db222167';
|
||||
|
||||
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||
|
||||
final class InstallBasicShortcutFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<bool>, String?> {
|
||||
with
|
||||
$FunctionalFamilyOverride<
|
||||
FutureOr<bool>,
|
||||
({String? overrideName, String? contextId})
|
||||
> {
|
||||
InstallBasicShortcutFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
@@ -416,8 +481,13 @@ final class InstallBasicShortcutFamily extends $Family
|
||||
|
||||
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||
|
||||
InstallBasicShortcutProvider call({String? overrideName}) =>
|
||||
InstallBasicShortcutProvider._(argument: overrideName, from: this);
|
||||
InstallBasicShortcutProvider call({
|
||||
String? overrideName,
|
||||
String? contextId,
|
||||
}) => InstallBasicShortcutProvider._(
|
||||
argument: (overrideName: overrideName, contextId: contextId),
|
||||
from: this,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => r'installBasicShortcutProvider';
|
||||
|
||||
+307
-91
@@ -18,41 +18,67 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/tabs/data/entities/isolation_context.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
|
||||
/// 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, null if dismissed.
|
||||
Future<bool?> showPwaInstallBottomSheet(
|
||||
/// Result of the install configuration sheets.
|
||||
class PwaInstallConfig {
|
||||
final String name;
|
||||
final String? contextId;
|
||||
|
||||
const PwaInstallConfig({required this.name, required this.contextId});
|
||||
}
|
||||
|
||||
/// Result of the shortcut choice sheet (basic vs app + config).
|
||||
class ShortcutInstallConfig {
|
||||
final ShortcutInstallType type;
|
||||
final String name;
|
||||
final String? contextId;
|
||||
|
||||
const ShortcutInstallConfig({
|
||||
required this.type,
|
||||
required this.name,
|
||||
required this.contextId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Shows a bottom sheet to confirm installing a PWA with an editable name
|
||||
/// and a storage (contextId) selection.
|
||||
Future<PwaInstallConfig?> showPwaInstallBottomSheet(
|
||||
BuildContext context, {
|
||||
required String name,
|
||||
required String defaultName,
|
||||
required Uri url,
|
||||
}) {
|
||||
return showModalBottomSheet<bool>(
|
||||
return showModalBottomSheet<ShortcutInstallConfig>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
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),
|
||||
),
|
||||
],
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: _InstallConfigSheet(
|
||||
defaultName: defaultName,
|
||||
url: url,
|
||||
showAppOption: true,
|
||||
showShortcutOption: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
).then((result) {
|
||||
if (result == null) return null;
|
||||
return PwaInstallConfig(name: result.name, contextId: result.contextId);
|
||||
});
|
||||
}
|
||||
|
||||
/// Shows a bottom sheet for non-manifest sites offering a choice between
|
||||
@@ -60,99 +86,289 @@ Future<bool?> showPwaInstallBottomSheet(
|
||||
///
|
||||
/// [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(
|
||||
Future<ShortcutInstallConfig?> showShortcutChoiceBottomSheet(
|
||||
BuildContext context, {
|
||||
required String name,
|
||||
required String defaultName,
|
||||
required Uri url,
|
||||
required bool showAppOption,
|
||||
}) {
|
||||
return showModalBottomSheet<ShortcutInstallType>(
|
||||
return showModalBottomSheet<ShortcutInstallConfig>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
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),
|
||||
),
|
||||
],
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: _InstallConfigSheet(
|
||||
defaultName: defaultName,
|
||||
url: url,
|
||||
showAppOption: showAppOption,
|
||||
showShortcutOption: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ShortcutSheetHeader extends StatelessWidget {
|
||||
final String name;
|
||||
final Uri url;
|
||||
/// Internal storage options backing the radio selector.
|
||||
sealed class _StorageOption {
|
||||
const _StorageOption();
|
||||
String? get contextId;
|
||||
}
|
||||
|
||||
const _ShortcutSheetHeader({required this.name, required this.url});
|
||||
class _StorageDefault extends _StorageOption {
|
||||
const _StorageDefault();
|
||||
@override
|
||||
String? get contextId => null;
|
||||
}
|
||||
|
||||
class _StorageContainer extends _StorageOption {
|
||||
final String label;
|
||||
@override
|
||||
final String contextId;
|
||||
const _StorageContainer({required this.label, required this.contextId});
|
||||
}
|
||||
|
||||
class _StorageInheritIsolated extends _StorageOption {
|
||||
@override
|
||||
final String contextId;
|
||||
const _StorageInheritIsolated(this.contextId);
|
||||
}
|
||||
|
||||
class _StorageNewIsolated extends _StorageOption {
|
||||
@override
|
||||
final String contextId;
|
||||
_StorageNewIsolated() : contextId = newIsolatedContextId();
|
||||
}
|
||||
|
||||
class _InstallConfigSheet extends HookConsumerWidget {
|
||||
final String defaultName;
|
||||
final Uri url;
|
||||
final bool showAppOption;
|
||||
final bool showShortcutOption;
|
||||
|
||||
const _InstallConfigSheet({
|
||||
required this.defaultName,
|
||||
required this.url,
|
||||
required this.showAppOption,
|
||||
required this.showShortcutOption,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
final nameController = useTextEditingController(text: defaultName);
|
||||
|
||||
final selectedTabId = ref.watch(selectedTabProvider);
|
||||
final tabContextId = selectedTabId != null
|
||||
? ref.watch(tabStateProvider(selectedTabId))?.contextId
|
||||
: null;
|
||||
final containerAsync = ref.watch(selectedContainerDataProvider);
|
||||
final containerData = containerAsync.asData?.value;
|
||||
|
||||
final options = useMemoized<List<_StorageOption>>(
|
||||
() {
|
||||
final list = <_StorageOption>[const _StorageDefault()];
|
||||
|
||||
// If the current tab is in an isolated context, offer to inherit it.
|
||||
if (isIsolatedContextId(tabContextId)) {
|
||||
list.add(_StorageInheritIsolated(tabContextId!));
|
||||
}
|
||||
|
||||
// If a regular (non-isolated) container is active, offer it.
|
||||
final containerContextId = containerData?.metadata.contextualIdentity;
|
||||
if (containerData != null &&
|
||||
containerContextId != null &&
|
||||
!isIsolatedContextId(containerContextId)) {
|
||||
list.add(
|
||||
_StorageContainer(
|
||||
label: containerData.name ?? 'Container',
|
||||
contextId: containerContextId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
list.add(_StorageNewIsolated());
|
||||
return list;
|
||||
},
|
||||
[tabContextId, containerData?.id, containerData?.metadata.contextualIdentity],
|
||||
);
|
||||
|
||||
// Pick sensible default selection based on current context.
|
||||
final defaultIndex = useMemoized(() {
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
final o = options[i];
|
||||
if (o is _StorageInheritIsolated) return i;
|
||||
if (o is _StorageContainer) return i;
|
||||
}
|
||||
return 0;
|
||||
}, [options]);
|
||||
|
||||
final selectedIndex = useState(defaultIndex);
|
||||
final userTouched = useRef(false);
|
||||
|
||||
// When options resolve async (e.g. selected container stream arrives
|
||||
// after first build), re-sync the radio to the computed default — unless
|
||||
// the user has already made a choice.
|
||||
useEffect(() {
|
||||
if (!userTouched.value) {
|
||||
selectedIndex.value = defaultIndex;
|
||||
}
|
||||
return null;
|
||||
}, [defaultIndex]);
|
||||
|
||||
void submit(ShortcutInstallType type) {
|
||||
final name = nameController.text.trim();
|
||||
Navigator.of(context).pop(
|
||||
ShortcutInstallConfig(
|
||||
type: type,
|
||||
name: name.isEmpty ? defaultName : name,
|
||||
contextId: options[selectedIndex.value].contextId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
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: UriBreadcrumb(
|
||||
uri: url,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Text(
|
||||
'Storage',
|
||||
style: textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
RadioGroup<int>(
|
||||
groupValue: selectedIndex.value,
|
||||
onChanged: (v) {
|
||||
if (v != null) {
|
||||
userTouched.value = true;
|
||||
selectedIndex.value = v;
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < options.length; i++)
|
||||
_StorageTile(option: options[i], value: i),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
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: () => submit(ShortcutInstallType.app),
|
||||
),
|
||||
if (showShortcutOption)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.shortcut),
|
||||
title: const Text('Add Shortcut'),
|
||||
subtitle: const Text(
|
||||
'Opens as a standard tab in the browser.',
|
||||
),
|
||||
onTap: () => submit(ShortcutInstallType.shortcut),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StorageTile extends StatelessWidget {
|
||||
final _StorageOption option;
|
||||
final int value;
|
||||
|
||||
const _StorageTile({required this.option, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (title, subtitle, icon) = switch (option) {
|
||||
_StorageDefault() => (
|
||||
'Default',
|
||||
'Uses the default browser storage (no container).',
|
||||
Icons.public,
|
||||
),
|
||||
_StorageContainer(:final label) => (
|
||||
'Container "$label"',
|
||||
'Shares cookies and data with the selected container.',
|
||||
Icons.folder_outlined,
|
||||
),
|
||||
_StorageInheritIsolated() => (
|
||||
'Inherit current isolated context',
|
||||
'Shares storage with the currently open isolated session.',
|
||||
Icons.link,
|
||||
),
|
||||
_StorageNewIsolated() => (
|
||||
'New isolated context',
|
||||
'Creates a fresh storage jar just for this installation.',
|
||||
Icons.shield_outlined,
|
||||
),
|
||||
};
|
||||
|
||||
return RadioListTile<int>(
|
||||
value: value,
|
||||
dense: true,
|
||||
secondary: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+54
-31
@@ -32,44 +32,52 @@ import 'package:weblibre/utils/ui_helper.dart';
|
||||
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 defaultName =
|
||||
manifest?.shortName ?? manifest?.name ?? 'this web app';
|
||||
final tabState = selectedTabId != null
|
||||
? ref.read(tabStateProvider(selectedTabId))
|
||||
: null;
|
||||
final url = tabState?.url ?? Uri.parse('about:blank');
|
||||
|
||||
final confirmed = await showPwaInstallBottomSheet(
|
||||
final config = await showPwaInstallBottomSheet(
|
||||
context,
|
||||
name: name,
|
||||
defaultName: defaultName,
|
||||
url: url,
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
final success = await ref.read(installCurrentWebAppProvider.future);
|
||||
if (config == null) return;
|
||||
|
||||
if (context.mounted) {
|
||||
if (success) {
|
||||
showInfoMessage(context, '$name added to home screen');
|
||||
} else {
|
||||
showErrorMessage(
|
||||
context,
|
||||
'Failed to add $name. The site may not support installation.',
|
||||
);
|
||||
}
|
||||
final name = config.name;
|
||||
|
||||
try {
|
||||
final success = await ref.read(
|
||||
installCurrentWebAppProvider(
|
||||
overrideName: name == defaultName ? null : name,
|
||||
contextId: config.contextId,
|
||||
).future,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
if (success) {
|
||||
showInfoMessage(context, '$name added to home screen');
|
||||
} else {
|
||||
showErrorMessage(
|
||||
context,
|
||||
'Failed to add $name. The site may not support installation.',
|
||||
);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
logger.e('Failed to install PWA', error: e, stackTrace: stackTrace);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
logger.e('Failed to install PWA', error: e, stackTrace: stackTrace);
|
||||
|
||||
if (context.mounted) {
|
||||
var errorMessage = 'Failed to add $name to home screen';
|
||||
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);
|
||||
if (e is StateError) {
|
||||
errorMessage = 'No tab selected. Please try again.';
|
||||
}
|
||||
|
||||
showErrorMessage(context, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,28 +92,43 @@ Future<void> showShortcutInstallDialog(
|
||||
if (selectedTabId == null) return;
|
||||
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId));
|
||||
final name = tabState?.title ?? 'this site';
|
||||
final defaultName = tabState?.title.trim().isNotEmpty == true
|
||||
? 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(
|
||||
final config = await showShortcutChoiceBottomSheet(
|
||||
context,
|
||||
name: name,
|
||||
defaultName: defaultName,
|
||||
url: url,
|
||||
showAppOption: showAppOption,
|
||||
);
|
||||
|
||||
if (choice == null) return;
|
||||
if (config == null) return;
|
||||
|
||||
final name = config.name;
|
||||
final overrideName = name == defaultName ? null : name;
|
||||
|
||||
try {
|
||||
final bool success;
|
||||
switch (choice) {
|
||||
switch (config.type) {
|
||||
case ShortcutInstallType.shortcut:
|
||||
success = await ref.read(installBasicShortcutProvider().future);
|
||||
success = await ref.read(
|
||||
installBasicShortcutProvider(
|
||||
overrideName: overrideName,
|
||||
contextId: config.contextId,
|
||||
).future,
|
||||
);
|
||||
case ShortcutInstallType.app:
|
||||
success = await ref.read(installCurrentWebAppProvider.future);
|
||||
success = await ref.read(
|
||||
installCurrentWebAppProvider(
|
||||
overrideName: overrideName,
|
||||
contextId: config.contextId,
|
||||
).future,
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
|
||||
+9
-4
@@ -51,21 +51,26 @@ class SelectedContainer extends _$SelectedContainer {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<SetContainerResult> setContainerId(String id) async {
|
||||
Future<SetContainerResult> setContainerId(
|
||||
String id, {
|
||||
bool Function()? shouldApply,
|
||||
}) async {
|
||||
final container = await ref
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.getContainerData(id);
|
||||
|
||||
if (ref.mounted && container != null) {
|
||||
bool canApply() => shouldApply?.call() ?? true;
|
||||
|
||||
if (ref.mounted && container != null && canApply()) {
|
||||
if (container.metadata.useProxy) {
|
||||
final proxyPluginHealthy = await GeckoContainerProxyService()
|
||||
.healthcheck();
|
||||
|
||||
if (proxyPluginHealthy) {
|
||||
if (ref.mounted && proxyPluginHealthy && canApply()) {
|
||||
state = id;
|
||||
return SetContainerResult.successHasProxy;
|
||||
}
|
||||
} else {
|
||||
} else if (canApply()) {
|
||||
state = id;
|
||||
return SetContainerResult.success;
|
||||
}
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ final class SelectedContainerProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$selectedContainerHash() => r'3d30966f0b8a8ee091afb48fb045be2274f4f417';
|
||||
String _$selectedContainerHash() => r'1f0828ce1d3f8b88fd2731a7aae2602a7323092c';
|
||||
|
||||
abstract class _$SelectedContainer extends $Notifier<String?> {
|
||||
String? build();
|
||||
|
||||
+15
-3
@@ -36,8 +36,14 @@ import 'package:weblibre/features/user/domain/repositories/general_settings.dart
|
||||
/// Long pressing opens the edit screen for the selected container.
|
||||
class CompactContainerSelector extends ConsumerWidget {
|
||||
final ContainerData? selectedContainer;
|
||||
final Future<void> Function(ContainerSelectionResult selection)?
|
||||
onSelectionChanged;
|
||||
|
||||
const CompactContainerSelector({super.key, this.selectedContainer});
|
||||
const CompactContainerSelector({
|
||||
super.key,
|
||||
this.selectedContainer,
|
||||
this.onSelectionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -73,6 +79,14 @@ class CompactContainerSelector extends ConsumerWidget {
|
||||
onPressed: () async {
|
||||
final selection = await const ContainerSelectionRoute()
|
||||
.push<ContainerSelectionResult?>(context);
|
||||
if (selection == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onSelectionChanged != null) {
|
||||
await onSelectionChanged!(selection);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (selection) {
|
||||
case ContainerSelectionSelected(:final containerId):
|
||||
@@ -81,8 +95,6 @@ class CompactContainerSelector extends ConsumerWidget {
|
||||
.setContainerId(containerId);
|
||||
case ContainerSelectionUnassigned():
|
||||
ref.read(selectedContainerProvider.notifier).clearContainer();
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
enum IntentContainerMode {
|
||||
useSelected('use-selected'),
|
||||
unassigned('unassigned'),
|
||||
specific('specific');
|
||||
|
||||
const IntentContainerMode(this.wireValue);
|
||||
|
||||
final String wireValue;
|
||||
|
||||
String? get queryValueOrNull =>
|
||||
this == IntentContainerMode.useSelected ? null : wireValue;
|
||||
|
||||
static IntentContainerMode fromWireValue(
|
||||
String? wireValue, {
|
||||
String? contextId,
|
||||
}) {
|
||||
for (final mode in values) {
|
||||
if (mode.wireValue == wireValue) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
|
||||
if (contextId != null) {
|
||||
return IntentContainerMode.specific;
|
||||
}
|
||||
|
||||
return IntentContainerMode.useSelected;
|
||||
}
|
||||
}
|
||||
@@ -18,29 +18,42 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
|
||||
import 'package:weblibre/utils/input_classification.dart';
|
||||
|
||||
sealed class SharedContent with FastEquatable {
|
||||
final String? contextId;
|
||||
final IntentContainerMode containerMode;
|
||||
|
||||
SharedContent({this.contextId});
|
||||
SharedContent({
|
||||
this.contextId,
|
||||
this.containerMode = IntentContainerMode.useSelected,
|
||||
});
|
||||
|
||||
factory SharedContent.parse(String content, {String? contextId}) {
|
||||
factory SharedContent.parse(
|
||||
String content, {
|
||||
String? contextId,
|
||||
IntentContainerMode containerMode = IntentContainerMode.useSelected,
|
||||
}) {
|
||||
if (parseSharedIntentUrl(content) case final Uri uri) {
|
||||
return SharedUrl(uri, contextId: contextId);
|
||||
return SharedUrl(uri, contextId: contextId, containerMode: containerMode);
|
||||
} else {
|
||||
return SharedText(content, contextId: contextId);
|
||||
return SharedText(
|
||||
content,
|
||||
contextId: contextId,
|
||||
containerMode: containerMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [contextId];
|
||||
List<Object?> get hashParameters => [contextId, containerMode];
|
||||
}
|
||||
|
||||
final class SharedUrl extends SharedContent {
|
||||
final Uri url;
|
||||
|
||||
SharedUrl(this.url, {super.contextId});
|
||||
SharedUrl(this.url, {super.contextId, super.containerMode});
|
||||
|
||||
@override
|
||||
String toString() => url.toString();
|
||||
@@ -52,7 +65,7 @@ final class SharedUrl extends SharedContent {
|
||||
final class SharedText extends SharedContent {
|
||||
final String text;
|
||||
|
||||
SharedText(this.text, {super.contextId});
|
||||
SharedText(this.text, {super.contextId, super.containerMode});
|
||||
|
||||
@override
|
||||
String toString() => text;
|
||||
|
||||
@@ -28,93 +28,109 @@ import 'package:uri_to_file/uri_to_file.dart' as uri_to_file;
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/data/models/received_intent_parameter.dart';
|
||||
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
|
||||
|
||||
part 'sharing_intent.g.dart';
|
||||
|
||||
StreamTransformer<Intent, ReceivedIntentParameter>
|
||||
_buildSharingIntentTransformer(IntentGatekeeper gatekeeper) =>
|
||||
StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
|
||||
handleData: (intent, sink) async {
|
||||
// PWA shortcut intents carry our own signed context id — always allow.
|
||||
final pwaContextId =
|
||||
intent.action == 'android.intent.action.VIEW'
|
||||
? intent.extra['pwa_context_id'] as String?
|
||||
: null;
|
||||
_buildSharingIntentTransformer(
|
||||
IntentGatekeeper gatekeeper,
|
||||
) => StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
|
||||
handleData: (intent, sink) async {
|
||||
final shortcutContextId = intent.action == 'android.intent.action.VIEW'
|
||||
? intent.extra['pwa_context_id'] as String?
|
||||
: null;
|
||||
final containerMode = intent.action == 'android.intent.action.VIEW'
|
||||
? IntentContainerMode.fromWireValue(
|
||||
intent.extra['shortcut_container_mode'] as String?,
|
||||
contextId: shortcutContextId,
|
||||
)
|
||||
: IntentContainerMode.useSelected;
|
||||
|
||||
if (pwaContextId == null) {
|
||||
final allowed = await gatekeeper.shouldAllow(
|
||||
fromPackageName: intent.fromPackageName,
|
||||
url: intent.data,
|
||||
);
|
||||
if (!allowed) {
|
||||
logger.i(
|
||||
'Blocked intent from ${intent.fromPackageName ?? 'unknown app'}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
final allowed = await gatekeeper.shouldAllow(
|
||||
fromPackageName: intent.fromPackageName,
|
||||
url: intent.data,
|
||||
);
|
||||
if (!allowed) {
|
||||
logger.i(
|
||||
'Blocked intent from ${intent.fromPackageName ?? 'unknown app'}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final data = switch (intent.action) {
|
||||
'android.intent.action.PROCESS_TEXT' =>
|
||||
intent.extra['android.intent.extra.PROCESS_TEXT'] as String?,
|
||||
'android.intent.action.WEB_SEARCH' =>
|
||||
intent.extra['query'] as String?,
|
||||
'android.intent.action.VIEW' => intent.data,
|
||||
'android.intent.action.SEND' =>
|
||||
intent.extra['android.intent.extra.STREAM'] as String? ??
|
||||
intent.extra['android.intent.extra.TEXT'] as String?,
|
||||
_ => null,
|
||||
};
|
||||
final data = switch (intent.action) {
|
||||
'android.intent.action.PROCESS_TEXT' =>
|
||||
intent.extra['android.intent.extra.PROCESS_TEXT'] as String?,
|
||||
'android.intent.action.WEB_SEARCH' => intent.extra['query'] as String?,
|
||||
'android.intent.action.VIEW' => intent.data,
|
||||
'android.intent.action.SEND' =>
|
||||
intent.extra['android.intent.extra.STREAM'] as String? ??
|
||||
intent.extra['android.intent.extra.TEXT'] as String?,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// Extract container context from shortcut intents
|
||||
final contextId = pwaContextId;
|
||||
// Extract container context from shortcut intents.
|
||||
final contextId = shortcutContextId;
|
||||
|
||||
if (data != null) {
|
||||
if (uri_to_file.isUriSupported(data)) {
|
||||
var path = data;
|
||||
if (p.extension(data).whenNotEmpty == null) {
|
||||
if (intent.mimeType.whenNotEmpty != null) {
|
||||
final ext = mime.extensionFromMime(intent.mimeType!);
|
||||
if (ext != null) {
|
||||
path = p.setExtension(path, '.$ext');
|
||||
} else {
|
||||
logger.w(
|
||||
'Could not determine file extension for: ${intent.mimeType}',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.w(
|
||||
'Received intent without extension and mime type $path',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final file = await uri_to_file.toFile(path);
|
||||
final mimeType = mime.lookupMimeType(file.path);
|
||||
switch (mimeType) {
|
||||
case 'application/pdf':
|
||||
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, contextId: contextId),
|
||||
if (data != null) {
|
||||
if (uri_to_file.isUriSupported(data)) {
|
||||
var path = data;
|
||||
if (p.extension(data).whenNotEmpty == null) {
|
||||
if (intent.mimeType.whenNotEmpty != null) {
|
||||
final ext = mime.extensionFromMime(intent.mimeType!);
|
||||
if (ext != null) {
|
||||
path = p.setExtension(path, '.$ext');
|
||||
} else {
|
||||
logger.w(
|
||||
'Could not determine file extension for: ${intent.mimeType}',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
sink.add(
|
||||
ReceivedIntentParameter(data, null, contextId: contextId),
|
||||
);
|
||||
logger.w('Received intent without extension and mime type $path');
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
final file = await uri_to_file.toFile(path);
|
||||
final mimeType = mime.lookupMimeType(file.path);
|
||||
switch (mimeType) {
|
||||
case 'application/pdf':
|
||||
sink.add(
|
||||
ReceivedIntentParameter(
|
||||
path,
|
||||
null,
|
||||
contextId: contextId,
|
||||
containerMode: containerMode,
|
||||
),
|
||||
);
|
||||
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,
|
||||
contextId: contextId,
|
||||
containerMode: containerMode,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
sink.add(
|
||||
ReceivedIntentParameter(
|
||||
data,
|
||||
null,
|
||||
contextId: contextId,
|
||||
containerMode: containerMode,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Raw<Stream<ReceivedIntentParameter>> sharingIntentStream(Ref ref) {
|
||||
|
||||
Reference in New Issue
Block a user