implemented container site assignements & improved routing

This commit is contained in:
Fabian Freund
2025-11-09 07:25:54 +01:00
parent da232bf8aa
commit 107b2a9aa6
45 changed files with 1104 additions and 264 deletions
+1 -1
View File
@@ -49,6 +49,6 @@ Future<GoRouter> router(Ref ref) async {
return GoRouter(
debugLogDiagnostics: true,
routes: $appRoutes,
initialLocation: initialLocation,
initialLocation: initialLocation ?? BrowserRoute().location,
);
}
+1 -1
View File
@@ -41,4 +41,4 @@ final class RouterProvider
}
}
String _$routerHash() => r'87b6e6c1097a0677f3d25570341b343b4392aa59';
String _$routerHash() => r'ab1d1e2ea27dd41fe78d7430bfeba87051d88d36';
+20 -12
View File
@@ -21,7 +21,7 @@ part of 'routes.dart';
@TypedGoRoute<BrowserRoute>(
name: BrowserRoute.name,
path: '/',
path: '/browser',
routes: [
TypedGoRoute<SearchRoute>(
name: 'SearchRoute',
@@ -44,11 +44,11 @@ part of 'routes.dart';
routes: [
TypedGoRoute<ContainerCreateRoute>(
name: 'ContainerCreateRoute',
path: 'create',
path: 'create/:containerData',
),
TypedGoRoute<ContainerEditRoute>(
name: 'ContainerEditRoute',
path: 'edit',
path: 'edit/:containerData',
),
],
),
@@ -135,37 +135,45 @@ class ContainerSelectionRoute extends GoRouteData
}
class ContainerEditRoute extends GoRouteData with $ContainerEditRoute {
final ContainerDataWithCount $extra;
final String containerData;
ContainerEditRoute(this.$extra);
ContainerEditRoute({required this.containerData});
@override
Widget build(BuildContext context, GoRouterState state) {
return ContainerEditScreen.edit(initialContainer: $extra);
return ContainerEditScreen.edit(
initialContainer: ContainerData.fromJson(
jsonDecode(containerData) as Map<String, dynamic>,
),
);
}
}
class ContainerCreateRoute extends GoRouteData with $ContainerCreateRoute {
final ContainerData $extra;
final String containerData;
ContainerCreateRoute(this.$extra);
ContainerCreateRoute({required this.containerData});
@override
Widget build(BuildContext context, GoRouterState state) {
return ContainerEditScreen.create(initialContainer: $extra);
return ContainerEditScreen.create(
initialContainer: ContainerData.fromJson(
jsonDecode(containerData) as Map<String, dynamic>,
),
);
}
}
class ContextMenuRoute extends GoRouteData with $ContextMenuRoute {
final String $extra;
final String hitResult;
const ContextMenuRoute(this.$extra);
const ContextMenuRoute({required this.hitResult});
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(
builder: (_) =>
ContextMenuDialog(hitResult: HitResultJson.fromJson($extra)),
ContextMenuDialog(hitResult: HitResultJson.fromJson(hitResult)),
);
}
}
+2
View File
@@ -21,6 +21,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/routing/widgets/dialog_page.dart';
import 'package:weblibre/features/about/presentation/screens/about.dart';
import 'package:weblibre/features/bangs/presentation/screens/categories.dart';
@@ -39,6 +40,7 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/c
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_edit.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_list.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_selection.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_sites.dart';
import 'package:weblibre/features/onboarding/presentation/onboarding.dart';
import 'package:weblibre/features/settings/presentation/screens/addon_collection.dart';
import 'package:weblibre/features/settings/presentation/screens/bang_settings.dart';
+6 -3
View File
@@ -90,15 +90,18 @@ class FeedEditRoute extends GoRouteData with $FeedEditRoute {
}
class FeedAddRoute extends GoRouteData with $FeedAddRoute {
final Uri? $extra;
final String? uri;
static const name = 'FeedAddRoute';
const FeedAddRoute({this.$extra});
const FeedAddRoute({required this.uri});
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return DialogPage(builder: (_) => AddFeedDialog(initialUri: $extra));
return DialogPage(
builder: (_) =>
AddFeedDialog(initialUri: uri.mapNotNull((uri) => Uri.tryParse(uri))),
);
}
}
+49 -45
View File
@@ -376,7 +376,7 @@ mixin $AddonCollectionRoute on GoRouteData {
}
RouteBase get $browserRoute => GoRouteData.$route(
path: '/',
path: '/browser',
name: 'BrowserRoute',
factory: $BrowserRoute._fromState,
routes: [
@@ -416,12 +416,12 @@ RouteBase get $browserRoute => GoRouteData.$route(
factory: $ContainerListRoute._fromState,
routes: [
GoRouteData.$route(
path: 'create',
path: 'create/:containerData',
name: 'ContainerCreateRoute',
factory: $ContainerCreateRoute._fromState,
),
GoRouteData.$route(
path: 'edit',
path: 'edit/:containerData',
name: 'ContainerEditRoute',
factory: $ContainerEditRoute._fromState,
),
@@ -449,7 +449,7 @@ mixin $BrowserRoute on GoRouteData {
static BrowserRoute _fromState(GoRouterState state) => BrowserRoute();
@override
String get location => GoRouteData.$location('/');
String get location => GoRouteData.$location('/browser');
@override
void go(BuildContext context) => context.go(location);
@@ -483,7 +483,7 @@ mixin $SearchRoute on GoRouteData {
@override
String get location => GoRouteData.$location(
'/search/${Uri.encodeComponent(_$TabTypeEnumMap[_self.tabType]!)}/${Uri.encodeComponent(_self.searchText)}',
'/browser/search/${Uri.encodeComponent(_$TabTypeEnumMap[_self.tabType]!)}/${Uri.encodeComponent(_self.searchText)}',
queryParams: {
if (_self.launchedFromIntent != false)
'launched-from-intent': _self.launchedFromIntent.toString(),
@@ -514,7 +514,7 @@ mixin $TorProxyRoute on GoRouteData {
static TorProxyRoute _fromState(GoRouterState state) => TorProxyRoute();
@override
String get location => GoRouteData.$location('/tor_proxy');
String get location => GoRouteData.$location('/browser/tor_proxy');
@override
void go(BuildContext context) => context.go(location);
@@ -534,7 +534,7 @@ mixin $HistoryRoute on GoRouteData {
static HistoryRoute _fromState(GoRouterState state) => HistoryRoute();
@override
String get location => GoRouteData.$location('/history');
String get location => GoRouteData.$location('/browser/history');
@override
void go(BuildContext context) => context.go(location);
@@ -554,7 +554,7 @@ mixin $TabViewRoute on GoRouteData {
static TabViewRoute _fromState(GoRouterState state) => TabViewRoute();
@override
String get location => GoRouteData.$location('/tab_view');
String get location => GoRouteData.$location('/browser/tab_view');
@override
void go(BuildContext context) => context.go(location);
@@ -572,27 +572,28 @@ mixin $TabViewRoute on GoRouteData {
mixin $ContextMenuRoute on GoRouteData {
static ContextMenuRoute _fromState(GoRouterState state) =>
ContextMenuRoute(state.extra as String);
ContextMenuRoute(hitResult: state.uri.queryParameters['hit-result']!);
ContextMenuRoute get _self => this as ContextMenuRoute;
@override
String get location => GoRouteData.$location('/context_menu');
String get location => GoRouteData.$location(
'/browser/context_menu',
queryParams: {'hit-result': _self.hitResult},
);
@override
void go(BuildContext context) => context.go(location, extra: _self.$extra);
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) =>
context.push<T>(location, extra: _self.$extra);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location, extra: _self.$extra);
context.pushReplacement(location);
@override
void replace(BuildContext context) =>
context.replace(location, extra: _self.$extra);
void replace(BuildContext context) => context.replace(location);
}
mixin $ContainerDraftRoute on GoRouteData {
@@ -600,7 +601,7 @@ mixin $ContainerDraftRoute on GoRouteData {
ContainerDraftRoute();
@override
String get location => GoRouteData.$location('/container_draft');
String get location => GoRouteData.$location('/browser/container_draft');
@override
void go(BuildContext context) => context.go(location);
@@ -621,7 +622,7 @@ mixin $ContainerListRoute on GoRouteData {
ContainerListRoute();
@override
String get location => GoRouteData.$location('/containers');
String get location => GoRouteData.$location('/browser/containers');
@override
void go(BuildContext context) => context.go(location);
@@ -639,52 +640,54 @@ mixin $ContainerListRoute on GoRouteData {
mixin $ContainerCreateRoute on GoRouteData {
static ContainerCreateRoute _fromState(GoRouterState state) =>
ContainerCreateRoute(state.extra as ContainerData);
ContainerCreateRoute(
containerData: state.pathParameters['containerData']!,
);
ContainerCreateRoute get _self => this as ContainerCreateRoute;
@override
String get location => GoRouteData.$location('/containers/create');
String get location => GoRouteData.$location(
'/browser/containers/create/${Uri.encodeComponent(_self.containerData)}',
);
@override
void go(BuildContext context) => context.go(location, extra: _self.$extra);
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) =>
context.push<T>(location, extra: _self.$extra);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location, extra: _self.$extra);
context.pushReplacement(location);
@override
void replace(BuildContext context) =>
context.replace(location, extra: _self.$extra);
void replace(BuildContext context) => context.replace(location);
}
mixin $ContainerEditRoute on GoRouteData {
static ContainerEditRoute _fromState(GoRouterState state) =>
ContainerEditRoute(state.extra as ContainerDataWithCount);
ContainerEditRoute(containerData: state.pathParameters['containerData']!);
ContainerEditRoute get _self => this as ContainerEditRoute;
@override
String get location => GoRouteData.$location('/containers/edit');
String get location => GoRouteData.$location(
'/browser/containers/edit/${Uri.encodeComponent(_self.containerData)}',
);
@override
void go(BuildContext context) => context.go(location, extra: _self.$extra);
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) =>
context.push<T>(location, extra: _self.$extra);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location, extra: _self.$extra);
context.pushReplacement(location);
@override
void replace(BuildContext context) =>
context.replace(location, extra: _self.$extra);
void replace(BuildContext context) => context.replace(location);
}
mixin $ContainerSelectionRoute on GoRouteData {
@@ -692,7 +695,7 @@ mixin $ContainerSelectionRoute on GoRouteData {
ContainerSelectionRoute();
@override
String get location => GoRouteData.$location('/select_container');
String get location => GoRouteData.$location('/browser/select_container');
@override
void go(BuildContext context) => context.go(location);
@@ -716,7 +719,7 @@ mixin $TabTreeRoute on GoRouteData {
@override
String get location => GoRouteData.$location(
'/tab_tree/${Uri.encodeComponent(_self.rootTabId)}',
'/browser/tab_tree/${Uri.encodeComponent(_self.rootTabId)}',
);
@override
@@ -743,7 +746,7 @@ mixin $OpenSharedContentRoute on GoRouteData {
@override
String get location => GoRouteData.$location(
'/open_content',
'/browser/open_content',
queryParams: {
if (_self.sharedUrl != 'about:blank') 'shared-url': _self.sharedUrl,
},
@@ -974,27 +977,28 @@ mixin $FeedListRoute on GoRouteData {
mixin $FeedAddRoute on GoRouteData {
static FeedAddRoute _fromState(GoRouterState state) =>
FeedAddRoute($extra: state.extra as Uri?);
FeedAddRoute(uri: state.uri.queryParameters['uri']);
FeedAddRoute get _self => this as FeedAddRoute;
@override
String get location => GoRouteData.$location('/feeds/add');
String get location => GoRouteData.$location(
'/feeds/add',
queryParams: {if (_self.uri != null) 'uri': _self.uri},
);
@override
void go(BuildContext context) => context.go(location, extra: _self.$extra);
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) =>
context.push<T>(location, extra: _self.$extra);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location, extra: _self.$extra);
context.pushReplacement(location);
@override
void replace(BuildContext context) =>
context.replace(location, extra: _self.$extra);
void replace(BuildContext context) => context.replace(location);
}
mixin $FeedArticleListRoute on GoRouteData {
@@ -388,6 +388,73 @@ class TabRepository extends _$TabRepository {
);
});
final containerSiteAssignementSub = eventSerivce.siteAssignementEvent.listen((
event,
) async {
if (event.tabId != null) {
// ignore: only_use_keep_alive_inside_keep_alive
final tabState = ref.read(tabStateProvider(event.tabId));
if (tabState != null) {
final uri = Uri.parse(event.url);
final originUri = event.originUrl.mapNotNull(Uri.parse);
final targetContainerId = await ref
.read(containerRepositoryProvider.notifier)
.siteAssignedContainerId(Uri.parse(uri.origin));
final containerData = await targetContainerId.mapNotNull(
(id) => ref
.read(containerRepositoryProvider.notifier)
.getContainerData(id),
);
if (containerData != null) {
final tabIsEmpty =
tabState.url == TabState.$default(tabState.id).url &&
tabState.historyState.items.isEmpty;
if (event.blocked || tabIsEmpty) {
await addTab(
url: uri,
private: tabState.isPrivate,
container: Value(containerData),
parentId: tabState.id,
);
if (tabIsEmpty) {
await closeTab(tabState.id);
}
} else {
final tabContainerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getContainerTabId(tabState.id);
if (targetContainerId != tabContainerId) {
if (originUri == null) {
await ref
.read(tabDataRepositoryProvider.notifier)
.assignContainer(tabState.id, containerData);
} else if (tabState.url == originUri) {
await ref
.read(tabDataRepositoryProvider.notifier)
.assignContainer(
tabState.id,
containerData,
closeOldTab: false,
);
} else {
logger.w(
'Could not match origin url for assignment ${tabState.url} to request ${event.originUrl}',
);
}
}
}
}
} else {
logger.w('Could not get tab for assignement ${tabState?.url}');
}
}
});
final tabContentSub = tabContentService.tabContentStream.listen((
content,
) async {
@@ -468,6 +535,7 @@ class TabRepository extends _$TabRepository {
tabStateDebouncer.dispose();
await tabAddedSub.cancel();
await tabContentSub.cancel();
await containerSiteAssignementSub.cancel();
});
}
}
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'c3dd50e6263df20ce803e979151997e0b10e0c01';
String _$tabRepositoryHash() => r'84c3abff33a3b2ad6af91b97d6d3c4e9efea4ac2';
abstract class _$TabRepository extends $Notifier<void> {
void build();
@@ -53,7 +53,7 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
ref.listen(
fireImmediately: true,
containersWithCountProvider.select(
watchContainersWithCountProvider.select(
(value) => EquatableValue(value.value),
),
(previous, next) async {
@@ -134,5 +134,14 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
);
},
);
// ignore: only_use_keep_alive_inside_keep_alive
ref.listen(watchAllAssignedSitesProvider, (previous, next) async {
if (next.hasValue) {
await ref
.read(torProxyRepositoryProvider.notifier)
.setSiteAssignments(next.requireValue);
}
});
}
}
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
}
String _$proxySettingsReplicationHash() =>
r'f58752de40db3b59553f86b56ab6c4558dda988d';
r'9ded7af1d745e25c59e22edecea582e25230e5e7';
abstract class _$ProxySettingsReplication extends $Notifier<void> {
void build();
@@ -83,7 +83,9 @@ class BrowserScreen extends HookConsumerWidget {
useOnStreamChange(
eventService.longPressEvent,
onData: (event) async {
await ContextMenuRoute(event.hitResult.toJson()).push(context);
await ContextMenuRoute(
hitResult: event.hitResult.toJson(),
).push(context);
},
);
@@ -143,7 +143,7 @@ class _BrowserViewState extends ConsumerState<BrowserView>
if (next.value.mapNotNull(Uri.tryParse) case final Uri url) {
if (GoRouterState.of(context).topRoute?.name != FeedAddRoute.name) {
if (ref.read(addFeedDialogBlockingProvider.notifier).canPush(url)) {
await FeedAddRoute($extra: url).push(context);
await FeedAddRoute(uri: url.toString()).push(context);
}
}
}
@@ -18,6 +18,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:fading_scroll/fading_scroll.dart';
@@ -353,7 +354,9 @@ class _TabViewHeader extends HookConsumerWidget {
.clearContainer();
},
onLongPress: (container) async {
await ContainerEditRoute(container).push(context);
await ContainerEditRoute(
containerData: jsonEncode(container.toJson()),
).push(context);
},
);
},
@@ -540,10 +543,11 @@ class ViewTabsWidget extends HookConsumerWidget {
);
} else {
if (newIndex < oldIndex) {
key = await containerRepository.getOrderKeyAfterTab(
filteredTabEntities.value[newIndex - 1].tabId,
containerId,
);
key = (await containerRepository
.getOrderKeyAfterTab(
filteredTabEntities.value[newIndex - 1].tabId,
containerId,
))!;
} else {
key = await containerRepository
.getOrderKeyBeforeTab(
@@ -17,6 +17,7 @@
* 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 'dart:ui';
import 'package:drift/drift.dart';
@@ -24,6 +25,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/database/daos/con
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
@DriftAccessor()
class ContainerDao extends DatabaseAccessor<TabDatabase>
@@ -115,7 +117,7 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
);
}
SingleSelectable<String> generateOrderKeyAfterTabId(
SingleOrNullSelectable<String> generateOrderKeyAfterTabId(
String? containerId,
String tabId,
) {
@@ -134,4 +136,26 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
tabId: tabId,
);
}
SingleSelectable<bool> isSiteAssignedToContainer(Uri uri) {
return db.definitionsDrift.isSiteAssignedToContainer(uri: uri.origin);
}
SingleSelectable<bool> areSitesAvailable(
Iterable<Uri> origins,
String ignoredContainerId,
) {
return db.definitionsDrift.areSitesAvailable(
ignoreContainerId: ignoredContainerId,
uriList: jsonEncode(origins.map((value) => value.origin).toList()),
);
}
SingleSelectable<String> siteAssignedContainerId(Uri uri) {
return db.definitionsDrift.siteAssignedContainerId(uri: uri.origin);
}
Selectable<SiteAssignment> allAssignedSites() {
return db.definitionsDrift.allAssignedSites();
}
}
@@ -62,11 +62,14 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
Future<String> _generateOrderKey({
required Value<String?> parentId,
required Value<String?> containerId,
}) {
}) async {
if (parentId.value.isNotEmpty) {
return db.containerDao
.generateOrderKeyAfterTabId(containerId.value, parentId.value!)
.getSingle();
return await db.containerDao
.generateOrderKeyAfterTabId(containerId.value, parentId.value!)
.getSingleOrNull() ??
await db.containerDao
.generateLeadingOrderKey(containerId.value)
.getSingle();
} else {
return db.containerDao
.generateLeadingOrderKey(containerId.value)
@@ -1,6 +1,7 @@
import 'package:weblibre/data/database/converters/color.dart';
import 'package:weblibre/data/database/converters/uri.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/converters/container_metadata_converter.dart';
@@ -273,3 +274,41 @@ nextTabByOrderKey(:tab_id AS TEXT, :container_id AS TEXT OR NULL, :skip_containe
SELECT next_tab_id
FROM ranked_tabs
WHERE id = :tab_id;
isSiteAssignedToContainer:
SELECT EXISTS (
SELECT 1
FROM container
CROSS JOIN json_each(container.metadata, '$.assignedSites')
WHERE json_each.value = :uri
) AS existing;
areSitesAvailable:
SELECT NOT EXISTS (
SELECT 1
FROM container
CROSS JOIN json_each(container.metadata, '$.assignedSites')
WHERE json_each.value IN (
SELECT value
FROM json_each(:uriList)
) AND
container.id IS NOT :ignore_container_id
) AS existing;
siteAssignedContainerId:
SELECT id
FROM container
WHERE EXISTS (
SELECT 1
FROM json_each(container.metadata, '$.assignedSites')
WHERE value = :uri
);
allAssignedSites WITH SiteAssignment:
SELECT
container.id,
COALESCE(container.metadata ->> '$.contextualIdentity', 'general') AS contextualIdentity,
value AS assigned_site
FROM container
CROSS JOIN json_each(container.metadata, '$.assignedSites')
WHERE value IS NOT NULL;
@@ -13,6 +13,8 @@ import 'package:weblibre/data/database/converters/uri.dart' as i6;
import 'package:drift/internal/modular.dart' as i7;
import 'package:weblibre/features/geckoview/features/tabs/data/models/tab_query_result.dart'
as i8;
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart'
as i9;
typedef $ContainerCreateCompanionBuilder =
i3.ContainerCompanion Function({
@@ -2277,6 +2279,50 @@ class DefinitionsDrift extends i7.ModularAccessor {
).map((i0.QueryRow row) => row.readNullable<String>('next_tab_id'));
}
i0.Selectable<bool> isSiteAssignedToContainer({String? uri}) {
return customSelect(
'SELECT EXISTS (SELECT 1 AS _c0 FROM container CROSS JOIN json_each(container.metadata, \'\$.assignedSites\')WHERE json_each.value = ?1) AS existing',
variables: [i0.Variable<String>(uri)],
readsFrom: {container},
).map((i0.QueryRow row) => row.read<bool>('existing'));
}
i0.Selectable<bool> areSitesAvailable({
required String uriList,
required String ignoreContainerId,
}) {
return customSelect(
'SELECT NOT EXISTS (SELECT 1 AS _c0 FROM container CROSS JOIN json_each(container.metadata, \'\$.assignedSites\')WHERE json_each.value IN (SELECT value FROM json_each(?1)) AND container.id IS NOT ?2) AS existing',
variables: [
i0.Variable<String>(uriList),
i0.Variable<String>(ignoreContainerId),
],
readsFrom: {container},
).map((i0.QueryRow row) => row.read<bool>('existing'));
}
i0.Selectable<String> siteAssignedContainerId({String? uri}) {
return customSelect(
'SELECT id FROM container WHERE EXISTS (SELECT 1 AS _c0 FROM json_each(container.metadata, \'\$.assignedSites\')WHERE value = ?1)',
variables: [i0.Variable<String>(uri)],
readsFrom: {container},
).map((i0.QueryRow row) => row.read<String>('id'));
}
i0.Selectable<i9.SiteAssignment> allAssignedSites() {
return customSelect(
'SELECT container.id, COALESCE(container.metadata ->> \'\$.contextualIdentity\', \'general\') AS contextualIdentity, value AS assigned_site FROM container CROSS JOIN json_each(container.metadata, \'\$.assignedSites\')WHERE value IS NOT NULL',
variables: [],
readsFrom: {container},
).map(
(i0.QueryRow row) => i9.SiteAssignment(
id: row.read<String>('id'),
contextualIdentity: row.read<String>('contextualIdentity'),
assignedSite: row.readNullable<String>('assigned_site'),
),
);
}
i3.TabFts get tabFts => i7.ReadDatabaseContainer(
attachedDatabase,
).resultSet<i3.TabFts>('tab_fts');
@@ -21,6 +21,7 @@ import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/widgets.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:weblibre/data/database/converters/color.dart';
import 'package:weblibre/data/database/converters/icon_data.dart';
part 'container_data.g.dart';
@@ -72,11 +73,14 @@ class ContainerMetadata with FastEquatable {
@JsonKey(defaultValue: false)
final bool useProxy;
final List<Uri>? assignedSites;
ContainerMetadata({
required this.iconData,
required this.contextualIdentity,
required this.authSettings,
required this.useProxy,
required this.assignedSites,
});
ContainerMetadata.withDefaults({
@@ -84,11 +88,13 @@ class ContainerMetadata with FastEquatable {
String? contextualIdentity,
ContainerAuthSettings? authSettings,
bool? useProxy,
List<Uri>? assignedSites,
}) : this(
iconData: iconData,
contextualIdentity: contextualIdentity,
authSettings: authSettings ?? ContainerAuthSettings.withDefaults(),
useProxy: useProxy ?? false,
assignedSites: assignedSites,
);
factory ContainerMetadata.fromJson(Map<String, dynamic> json) =>
@@ -102,13 +108,16 @@ class ContainerMetadata with FastEquatable {
contextualIdentity,
authSettings,
useProxy,
assignedSites,
];
}
@JsonSerializable()
@CopyWith()
class ContainerData with FastEquatable {
final String id;
final String? name;
@ColorJsonConverter()
final Color color;
final ContainerMetadata metadata;
@@ -119,10 +128,16 @@ class ContainerData with FastEquatable {
ContainerMetadata? metadata,
}) : metadata = metadata ?? ContainerMetadata.withDefaults();
factory ContainerData.fromJson(Map<String, dynamic> json) =>
_$ContainerDataFromJson(json);
Map<String, dynamic> toJson() => _$ContainerDataToJson(this);
@override
List<Object?> get hashParameters => [id, name, color, metadata];
}
@JsonSerializable()
class ContainerDataWithCount extends ContainerData {
final int? tabCount;
@@ -134,6 +149,12 @@ class ContainerDataWithCount extends ContainerData {
required this.tabCount,
});
factory ContainerDataWithCount.fromJson(Map<String, dynamic> json) =>
_$ContainerDataWithCountFromJson(json);
@override
Map<String, dynamic> toJson() => _$ContainerDataWithCountToJson(this);
@override
List<Object?> get hashParameters => [...super.hashParameters, tabCount];
}
@@ -98,6 +98,8 @@ abstract class _$ContainerMetadataCWProxy {
ContainerMetadata useProxy(bool useProxy);
ContainerMetadata assignedSites(List<Uri>? assignedSites);
/// 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 `ContainerMetadata(...).copyWith.fieldName(value)`.
///
@@ -110,6 +112,7 @@ abstract class _$ContainerMetadataCWProxy {
String? contextualIdentity,
ContainerAuthSettings authSettings,
bool useProxy,
List<Uri>? assignedSites,
});
}
@@ -134,6 +137,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
@override
ContainerMetadata useProxy(bool useProxy) => call(useProxy: useProxy);
@override
ContainerMetadata assignedSites(List<Uri>? assignedSites) =>
call(assignedSites: assignedSites);
@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 `ContainerMetadata(...).copyWith.fieldName(value)`.
@@ -147,6 +154,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
Object? contextualIdentity = const $CopyWithPlaceholder(),
Object? authSettings = const $CopyWithPlaceholder(),
Object? useProxy = const $CopyWithPlaceholder(),
Object? assignedSites = const $CopyWithPlaceholder(),
}) {
return ContainerMetadata(
iconData: iconData == const $CopyWithPlaceholder()
@@ -166,6 +174,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
? _value.useProxy
// ignore: cast_nullable_to_non_nullable
: useProxy as bool,
assignedSites: assignedSites == const $CopyWithPlaceholder()
? _value.assignedSites
// ignore: cast_nullable_to_non_nullable
: assignedSites as List<Uri>?,
);
}
}
@@ -299,18 +311,23 @@ ContainerMetadata _$ContainerMetadataFromJson(Map<String, dynamic> json) =>
json['authSettings'] as Map<String, dynamic>,
),
useProxy: json['useProxy'] as bool? ?? false,
assignedSites: (json['assignedSites'] as List<dynamic>?)
?.map((e) => Uri.parse(e as String))
.toList(),
);
Map<String, dynamic> _$ContainerMetadataToJson(ContainerMetadata instance) =>
<String, dynamic>{
'iconData': _$JsonConverterToJson<Map<String, dynamic>, IconData>(
instance.iconData,
const IconDataJsonConverter().toJson,
),
'contextualIdentity': instance.contextualIdentity,
'authSettings': instance.authSettings.toJson(),
'useProxy': instance.useProxy,
};
Map<String, dynamic> _$ContainerMetadataToJson(
ContainerMetadata instance,
) => <String, dynamic>{
'iconData': _$JsonConverterToJson<Map<String, dynamic>, IconData>(
instance.iconData,
const IconDataJsonConverter().toJson,
),
'contextualIdentity': instance.contextualIdentity,
'authSettings': instance.authSettings.toJson(),
'useProxy': instance.useProxy,
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
};
Value? _$JsonConverterFromJson<Json, Value>(
Object? json,
@@ -321,3 +338,44 @@ Json? _$JsonConverterToJson<Json, Value>(
Value? value,
Json? Function(Value value) toJson,
) => value == null ? null : toJson(value);
ContainerData _$ContainerDataFromJson(
Map<String, dynamic> json,
) => ContainerData(
id: json['id'] as String,
name: json['name'] as String?,
color: const ColorJsonConverter().fromJson((json['color'] as num).toInt()),
metadata: json['metadata'] == null
? null
: ContainerMetadata.fromJson(json['metadata'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ContainerDataToJson(ContainerData instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'color': const ColorJsonConverter().toJson(instance.color),
'metadata': instance.metadata.toJson(),
};
ContainerDataWithCount _$ContainerDataWithCountFromJson(
Map<String, dynamic> json,
) => ContainerDataWithCount(
id: json['id'] as String,
name: json['name'] as String?,
color: const ColorJsonConverter().fromJson((json['color'] as num).toInt()),
metadata: json['metadata'] == null
? null
: ContainerMetadata.fromJson(json['metadata'] as Map<String, dynamic>),
tabCount: (json['tabCount'] as num?)?.toInt(),
);
Map<String, dynamic> _$ContainerDataWithCountToJson(
ContainerDataWithCount instance,
) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'color': const ColorJsonConverter().toJson(instance.color),
'metadata': instance.metadata.toJson(),
'tabCount': instance.tabCount,
};
@@ -0,0 +1,17 @@
import 'package:fast_equatable/fast_equatable.dart';
class SiteAssignment with FastEquatable {
final String id;
final String? contextualIdentity;
final Uri assignedSite;
SiteAssignment({
required this.id,
required this.contextualIdentity,
//Drift type casting issue should be non null
required String? assignedSite,
}) : assignedSite = Uri.parse(assignedSite!);
@override
List<Object?> get hashParameters => [id, contextualIdentity, assignedSite];
}
@@ -23,6 +23,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
import 'package:weblibre/features/search/util/tokenized_filter.dart';
@@ -118,3 +119,8 @@ Stream<String?> watchContainerTabId(Ref ref, String tabId) {
.getTabContainerId(tabId)
.watchSingleOrNull();
}
@Riverpod()
Stream<List<SiteAssignment>> watchAllAssignedSites(Ref ref) {
return ref.read(tabDatabaseProvider).containerDao.allAssignedSites().watch();
}
@@ -20,6 +20,7 @@
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
@@ -30,7 +31,16 @@ part 'container.g.dart';
@Riverpod(keepAlive: true)
class ContainerRepository extends _$ContainerRepository {
Future<void> addContainer(ContainerData container) {
Future<void> addContainer(ContainerData container) async {
if (container.metadata.assignedSites.isNotEmpty) {
if (!await areSitesAvailable(
container.metadata.assignedSites!,
container.id,
)) {
throw Exception('Sites already assigned to another container');
}
}
return ref.read(tabDatabaseProvider).containerDao.addContainer(container);
}
@@ -42,7 +52,16 @@ class ContainerRepository extends _$ContainerRepository {
.get();
}
Future<void> replaceContainer(ContainerData container) {
Future<void> replaceContainer(ContainerData container) async {
if (container.metadata.assignedSites.isNotEmpty) {
if (!await areSitesAvailable(
container.metadata.assignedSites!,
container.id,
)) {
throw Exception('Sites already assigned to another container');
}
}
return ref
.read(tabDatabaseProvider)
.containerDao
@@ -98,12 +117,12 @@ class ContainerRepository extends _$ContainerRepository {
.getSingle();
}
Future<String> getOrderKeyAfterTab(String tabId, String? containerId) {
Future<String?> getOrderKeyAfterTab(String tabId, String? containerId) {
return ref
.read(tabDatabaseProvider)
.containerDao
.generateOrderKeyAfterTabId(containerId, tabId)
.getSingle();
.getSingleOrNull();
}
Future<String> getOrderKeyBeforeTab(String tabId, String? containerId) {
@@ -126,6 +145,33 @@ class ContainerRepository extends _$ContainerRepository {
return randomColor;
}
Future<bool> isSiteAssignedToContainer(Uri uri) {
return ref
.read(tabDatabaseProvider)
.containerDao
.isSiteAssignedToContainer(uri)
.getSingle();
}
Future<bool> areSitesAvailable(
Iterable<Uri> origins,
String ignoreContainerId,
) {
return ref
.read(tabDatabaseProvider)
.containerDao
.areSitesAvailable(origins, ignoreContainerId)
.getSingle();
}
Future<String?> siteAssignedContainerId(Uri uri) {
return ref
.read(tabDatabaseProvider)
.containerDao
.siteAssignedContainerId(uri)
.getSingle();
}
@override
void build() {}
}
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
}
String _$containerRepositoryHash() =>
r'6d84d672e24ac3d2cb376e93cbbe61fc223d89b3';
r'8c9252a49a096c29279ad3066f6e5936e15524b2';
abstract class _$ContainerRepository extends $Notifier<void> {
void build();
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:fast_equatable/fast_equatable.dart';
@@ -198,7 +199,7 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget {
if (context.mounted) {
final result = await ContainerCreateRoute(
initialContainer,
containerData: jsonEncode(initialContainer.toJson()),
).push<ContainerData?>(context);
if (result != null) {
@@ -17,15 +17,18 @@
* 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 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.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/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/controllers/container_topic.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_sites.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart';
import 'package:weblibre/features/user/domain/services/local_authentication.dart';
import 'package:weblibre/presentation/icons/tor_icons.dart';
@@ -74,6 +77,7 @@ class ContainerEditScreen extends HookConsumerWidget {
);
final authSettings = useState(initialContainer.metadata.authSettings);
final useProxy = useState(initialContainer.metadata.useProxy);
final assignedSites = useState(initialContainer.metadata.assignedSites);
final textController = useTextEditingController(
text: initialContainer.name,
@@ -101,6 +105,7 @@ class ContainerEditScreen extends HookConsumerWidget {
contextualIdentity: contextualIdentity.value,
authSettings: authSettings.value,
useProxy: useProxy.value && contextualIdentity.value != null,
assignedSites: assignedSites.value,
),
);
@@ -299,6 +304,26 @@ class ContainerEditScreen extends HookConsumerWidget {
},
),
),
ListTile(
leading: const Icon(Icons.web),
title: const Text('Assigned Sites'),
trailing: const Icon(Icons.chevron_right),
contentPadding: EdgeInsets.zero,
onTap: () async {
final result = await showDialog<Set<Uri>>(
context: context,
builder: (context) => ContainerSitesScreen(
initialSites: assignedSites.value?.toSet() ?? {},
),
);
if (result.isEmpty) {
assignedSites.value = null;
} else {
assignedSites.value = result!.toList();
}
},
),
],
),
),
@@ -17,6 +17,8 @@
* 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:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
@@ -40,7 +42,7 @@ class ContainerListScreen extends HookConsumerWidget {
appBar: AppBar(title: const Text('Containers')),
body: HookConsumer(
builder: (context, ref, child) {
final containersAsync = ref.watch(containersWithCountProvider);
final containersAsync = ref.watch(watchContainersWithCountProvider);
final selectedContainer = ref.watch(selectedContainerProvider);
return Skeletonizer(
@@ -128,7 +130,9 @@ class ContainerListScreen extends HookConsumerWidget {
container,
isSelected: container.id == selectedContainer,
onTap: () async {
await ContainerEditRoute(container).push(context);
await ContainerEditRoute(
containerData: jsonEncode(container.toJson()),
).push(context);
},
),
);
@@ -157,7 +161,9 @@ class ContainerListScreen extends HookConsumerWidget {
if (context.mounted) {
await ContainerCreateRoute(
ContainerData(id: uuid.v7(), color: initialColor),
containerData: jsonEncode(
ContainerData(id: uuid.v7(), color: initialColor).toJson(),
),
).push(context);
}
},
@@ -17,6 +17,8 @@
* 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:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@@ -38,7 +40,7 @@ class ContainerSelectionScreen extends HookConsumerWidget {
appBar: AppBar(title: const Text('Select Container')),
body: HookConsumer(
builder: (context, ref, child) {
final containersAsync = ref.watch(containersWithCountProvider);
final containersAsync = ref.watch(watchContainersWithCountProvider);
return Skeletonizer(
enabled: containersAsync.isLoading,
@@ -84,7 +86,9 @@ class ContainerSelectionScreen extends HookConsumerWidget {
if (context.mounted) {
await ContainerCreateRoute(
ContainerData(id: uuid.v7(), color: initialColor),
containerData: jsonEncode(
ContainerData(id: uuid.v7(), color: initialColor).toJson(),
),
).push(context);
}
},
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/form_validators.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
class ContainerSitesScreen extends HookConsumerWidget {
final Set<Uri> initialSites;
const ContainerSitesScreen({super.key, required this.initialSites});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final textController = useTextEditingController();
final sites = useState(initialSites);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
context.pop(result ?? sites.value);
}
},
child: Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
leading: BackButton(
onPressed: () {
context.pop(sites.value);
},
),
title: const Text('Site Assignments'),
),
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Form(
key: formKey,
child: TextFormField(
decoration: InputDecoration(
label: const Text('Add Site'),
hintText: 'example.com',
floatingLabelBehavior: FloatingLabelBehavior.always,
suffix: TextButton(
onPressed: () {
if (formKey.currentState?.validate() == true) {
formKey.currentState?.save();
}
},
child: const Text('Add'),
),
),
controller: textController,
keyboardType: TextInputType.url,
validator: (value) {
final uriValid = validateUrl(
value,
onlyHttpProtocol: true,
eagerParsing: true,
);
if (uriValid != null) {
return uriValid;
}
final origin = Uri.parse(
uri_parser
.tryParseUrl(value, eagerParsing: true)!
.origin,
);
if (sites.value.contains(origin)) {
return 'This site has been already assigned';
}
return null;
},
onSaved: (newValue) async {
final origin = Uri.parse(
uri_parser
.tryParseUrl(newValue, eagerParsing: true)!
.origin,
);
final isAssigned = await ref
.read(containerRepositoryProvider.notifier)
.isSiteAssignedToContainer(origin);
if (!isAssigned) {
sites.value = {...sites.value, origin};
} else {
final assignedContainerId = await ref
.read(containerRepositoryProvider.notifier)
.siteAssignedContainerId(origin);
final assignedContainer = await assignedContainerId
.mapNotNull(
(id) => ref
.read(containerRepositoryProvider.notifier)
.getContainerData(id),
);
if (context.mounted) {
ui_helper.showErrorMessage(
context,
(assignedContainer?.name.isNotEmpty ?? false)
? '$origin has already been assigned to container "${assignedContainer?.name}"'
: '$origin has already been assigned to another container',
);
}
}
textController.clear();
},
onFieldSubmitted: (_) {
if (formKey.currentState?.validate() == true) {
formKey.currentState?.save();
}
},
),
),
),
Expanded(
child: ListView.builder(
itemCount: sites.value.length,
itemBuilder: (context, index) {
final site = sites.value.elementAt(index);
return ListTile(
leading: UrlIcon([site], iconSize: 20),
title: Text(site.host),
subtitle: Text(site.toString()),
trailing: IconButton(
onPressed: () {
sites.value = {...sites.value}..remove(site);
},
icon: const Icon(Icons.delete),
),
);
},
),
),
],
),
),
),
);
}
}
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
part 'tor_proxy.g.dart';
@@ -35,6 +36,23 @@ class TorProxyRepository extends _$TorProxyRepository {
});
}
Future<void> setSiteAssignments(List<SiteAssignment> assignements) {
return _serviceLock.synchronized(() async {
await _waitHealthcheck().timeout(const Duration(seconds: 10));
return _service.setSiteAssignments(
Map.fromEntries(
assignements.map(
(e) => MapEntry(
e.assignedSite.origin,
e.contextualIdentity ?? 'general',
),
),
),
);
});
}
Future<void> _waitHealthcheck({
Duration timeout = const Duration(seconds: 15),
}) async {
@@ -42,7 +42,7 @@ final class TorProxyRepositoryProvider
}
String _$torProxyRepositoryHash() =>
r'3bdb83bdb1c5d1bb5d8953878cd587d042d30342';
r'83c2976750f3f7907274b1ae4f926cd1de89be83';
abstract class _$TorProxyRepository extends $Notifier<void> {
void build();
@@ -33,7 +33,7 @@ final class TorProxyServiceProvider
TorProxyService create() => TorProxyService();
}
String _$torProxyServiceHash() => r'21592493593c7c2a6cf1ac8311526a7fd6114b98';
String _$torProxyServiceHash() => r'c63adf96d7b0a865917b8d902071a0b3ae4dfcc6';
abstract class _$TorProxyService extends $AsyncNotifier<int?> {
FutureOr<int?> build();
@@ -67,7 +67,7 @@ class FeedListScreen extends HookConsumerWidget {
label: const Text('Feed'),
icon: const Icon(Icons.add),
onPressed: () async {
await const FeedAddRoute().push(context);
await const FeedAddRoute(uri: null).push(context);
},
),
);