add open in new tab feature (#161)
This commit is contained in:
+201
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' hide Column;
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
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:skeletonizer/skeletonizer.dart';
|
||||
import 'package:uuid/enums.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
sealed class _PickerResult {}
|
||||
|
||||
class _SelectedContainer extends _PickerResult {
|
||||
final ContainerData container;
|
||||
_SelectedContainer(this.container);
|
||||
}
|
||||
|
||||
class _CreateNewContainer extends _PickerResult {}
|
||||
|
||||
class OpenInContainer extends HookConsumerWidget {
|
||||
final HitResult hitResult;
|
||||
|
||||
const OpenInContainer({super.key, required this.hitResult});
|
||||
|
||||
static bool isSupported(HitResult hitResult) {
|
||||
return hitResult.isHttpLink();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListTile(
|
||||
leading: const Icon(MdiIcons.selectGroup),
|
||||
title: const Text('Open in container'),
|
||||
onTap: () async {
|
||||
final result = await showModalBottomSheet<_PickerResult?>(
|
||||
context: context,
|
||||
builder: (context) => _ContainerPickerSheet(),
|
||||
);
|
||||
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
final selectedContainer = switch (result) {
|
||||
_SelectedContainer(:final container) => container,
|
||||
_CreateNewContainer() => await () async {
|
||||
final draft = await ref
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.createNewContainer();
|
||||
|
||||
if (!context.mounted) return null;
|
||||
|
||||
return ContainerCreateRoute(
|
||||
containerData: jsonEncode(draft.toJson()),
|
||||
).push<ContainerData?>(context);
|
||||
}(),
|
||||
};
|
||||
|
||||
if (selectedContainer == null || !context.mounted) return;
|
||||
|
||||
final currentTab = ref.read(selectedTabStateProvider);
|
||||
final isPrivate =
|
||||
currentTab?.isPrivate ??
|
||||
ref
|
||||
.read(generalSettingsWithDefaultsProvider)
|
||||
.defaultCreateTabType ==
|
||||
TabType.private;
|
||||
|
||||
final tabId = await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: hitResult.tryGetLink(),
|
||||
parentId: currentTab?.id,
|
||||
selectTab: false,
|
||||
private: isPrivate,
|
||||
container: Value(selectedContainer),
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
final repo = ref.read(tabRepositoryProvider.notifier);
|
||||
|
||||
showTabSwitchMessage(
|
||||
context,
|
||||
onSwitch: () async {
|
||||
await repo.selectTab(tabId);
|
||||
},
|
||||
);
|
||||
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContainerPickerSheet extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final containersAsync = ref.watch(watchContainersWithCountProvider);
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Select Container',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Skeletonizer(
|
||||
enabled: containersAsync.isLoading,
|
||||
child: containersAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (containers) => FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
shrinkWrap: true,
|
||||
itemCount: containers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final container = containers[index];
|
||||
return ContainerListTile(
|
||||
container,
|
||||
isSelected: false,
|
||||
onTap: () => Navigator.pop(
|
||||
context,
|
||||
_SelectedContainer(container),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
error: (error, stackTrace) => Center(
|
||||
child: FailureWidget(
|
||||
title: 'Failed to load containers',
|
||||
exception: error,
|
||||
onRetry: () =>
|
||||
ref.invalidate(watchContainersWithCountProvider),
|
||||
),
|
||||
),
|
||||
loading: () => ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: 3,
|
||||
itemBuilder: (context, index) => ContainerListTile(
|
||||
ContainerData(
|
||||
id: Namespace.nil.value,
|
||||
color: Colors.transparent,
|
||||
),
|
||||
isSelected: false,
|
||||
onTap: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: const Text('New Container'),
|
||||
onTap: () => Navigator.pop(context, _CreateNewContainer()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import 'package:weblibre/features/geckoview/features/contextmenu/presentation/ca
|
||||
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_link.dart';
|
||||
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart';
|
||||
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/open_image_new_tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/open_new_tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/save_file.dart';
|
||||
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/save_image.dart';
|
||||
@@ -57,6 +58,8 @@ class ContextMenuDialog extends HookConsumerWidget {
|
||||
children: [
|
||||
if (OpenInNewTab.isSupported(hitResult))
|
||||
OpenInNewTab(hitResult: hitResult),
|
||||
if (OpenInContainer.isSupported(hitResult))
|
||||
OpenInContainer(hitResult: hitResult),
|
||||
if (CopyLink.isSupported(hitResult)) CopyLink(hitResult: hitResult),
|
||||
if (SaveFile.isSupported(hitResult)) SaveFile(hitResult: hitResult),
|
||||
if (ShareLink.isSupported(hitResult)) ShareLink(hitResult: hitResult),
|
||||
|
||||
@@ -92,7 +92,7 @@ final class PwaManifestStateProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$pwaManifestStateHash() => r'e5237d2aae5dce9cb805484eb2a09cc3f6aceda1';
|
||||
String _$pwaManifestStateHash() => r'1d173b69e96ab71b117e82b9a4134c6732a73343';
|
||||
|
||||
/// Manages PWA manifest state with a long-running subscription.
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import 'dart:ui';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/uuid.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
@@ -143,6 +144,12 @@ class ContainerRepository extends _$ContainerRepository {
|
||||
return randomColor;
|
||||
}
|
||||
|
||||
Future<ContainerData> createNewContainer() async {
|
||||
final initialColor = await unusedRandomContainerColor();
|
||||
|
||||
return ContainerData(id: uuid.v7(), color: initialColor);
|
||||
}
|
||||
|
||||
Future<bool> isSiteAssignedToContainer(Uri uri) {
|
||||
return ref
|
||||
.read(tabDatabaseProvider)
|
||||
|
||||
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
|
||||
}
|
||||
|
||||
String _$containerRepositoryHash() =>
|
||||
r'3154277f7a6029d6f5b9fcc62ba057e117248e0c';
|
||||
r'62c2b06970db4385ea0d2edc68bcf076c291a9b6';
|
||||
|
||||
abstract class _$ContainerRepository extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+15
-18
@@ -29,7 +29,6 @@ import 'package:nullability/nullability.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/core/uuid.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/utils/grid_calculations.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
@@ -196,13 +195,11 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget {
|
||||
? FloatingActionButton(
|
||||
child: const Icon(MdiIcons.folderPlus),
|
||||
onPressed: () async {
|
||||
final initialColor = await ref
|
||||
final initialContainer = await ref
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.unusedRandomContainerColor();
|
||||
.createNewContainer();
|
||||
|
||||
final initialContainer = ContainerData(
|
||||
id: uuid.v7(),
|
||||
color: initialColor,
|
||||
final namedContainer = initialContainer.copyWith(
|
||||
name:
|
||||
(ref.exists(
|
||||
tabsTopicProvider(EquatableValue(selectedTabs!)),
|
||||
@@ -218,21 +215,21 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget {
|
||||
: selectedContainer.value?.topic,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
final result = await ContainerCreateRoute(
|
||||
containerData: jsonEncode(initialContainer.toJson()),
|
||||
).push<ContainerData?>(context);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (result != null) {
|
||||
for (final tab in selectedTabs) {
|
||||
await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.assignContainer(tab, result);
|
||||
}
|
||||
final result = await ContainerCreateRoute(
|
||||
containerData: jsonEncode(namedContainer.toJson()),
|
||||
).push<ContainerData?>(context);
|
||||
|
||||
selectedContainerTabs.value = {};
|
||||
selectedContainer.value = null;
|
||||
if (result != null) {
|
||||
for (final tab in selectedTabs) {
|
||||
await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.assignContainer(tab, result);
|
||||
}
|
||||
|
||||
selectedContainerTabs.value = {};
|
||||
selectedContainer.value = null;
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -25,7 +25,6 @@ import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/core/uuid.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||
@@ -156,17 +155,15 @@ class ContainerListScreen extends HookConsumerWidget {
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () async {
|
||||
final initialColor = await ref
|
||||
final newContainer = await ref
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.unusedRandomContainerColor();
|
||||
.createNewContainer();
|
||||
|
||||
if (context.mounted) {
|
||||
await ContainerCreateRoute(
|
||||
containerData: jsonEncode(
|
||||
ContainerData(id: uuid.v7(), color: initialColor).toJson(),
|
||||
),
|
||||
).push(context);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
|
||||
await ContainerCreateRoute(
|
||||
containerData: jsonEncode(newContainer.toJson()),
|
||||
).push(context);
|
||||
},
|
||||
label: const Text('Container'),
|
||||
icon: const Icon(Icons.add),
|
||||
|
||||
+9
-11
@@ -24,8 +24,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:uuid/enums.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/core/uuid.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
@@ -73,7 +73,7 @@ class ContainerSelectionScreen extends HookConsumerWidget {
|
||||
loading: () => ListView.builder(
|
||||
itemCount: 3,
|
||||
itemBuilder: (context, index) => ContainerListTile(
|
||||
ContainerData(id: 'null', color: Colors.transparent),
|
||||
ContainerData(id: Namespace.nil.value, color: Colors.transparent),
|
||||
isSelected: false,
|
||||
onTap: null,
|
||||
),
|
||||
@@ -82,17 +82,15 @@ class ContainerSelectionScreen extends HookConsumerWidget {
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () async {
|
||||
final initialColor = await ref
|
||||
final newContainer = await ref
|
||||
.read(containerRepositoryProvider.notifier)
|
||||
.unusedRandomContainerColor();
|
||||
.createNewContainer();
|
||||
|
||||
if (context.mounted) {
|
||||
await ContainerCreateRoute(
|
||||
containerData: jsonEncode(
|
||||
ContainerData(id: uuid.v7(), color: initialColor).toJson(),
|
||||
),
|
||||
).push(context);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
|
||||
await ContainerCreateRoute(
|
||||
containerData: jsonEncode(newContainer.toJson()),
|
||||
).push(context);
|
||||
},
|
||||
label: const Text('Container'),
|
||||
icon: const Icon(Icons.add),
|
||||
|
||||
Reference in New Issue
Block a user