prepare for multiple apps

This commit is contained in:
Fabian Freund
2026-04-06 12:23:11 +02:00
parent bd1600e8dc
commit 5afc323f04
904 changed files with 29 additions and 29 deletions
@@ -0,0 +1,50 @@
/*
* 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 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
part 'bottom_sheet.g.dart';
@Riverpod(keepAlive: true)
class BottomSheetController extends _$BottomSheetController {
@override
Sheet? build() {
return null;
}
///We depend on a listener that updates/syncs UI to open the sheet
// ignore: use_setters_to_change_properties api decision
void show(Sheet sheet) {
state = sheet;
}
///We depend on a listener that updates/syncs UI to close the sheet
void requestDismiss() {
state = null;
}
///This is called by UI when the sheet gets closed
void closed(Sheet sheet) {
if (sheet == stateOrNull) {
state = null;
}
}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bottom_sheet.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BottomSheetController)
final bottomSheetControllerProvider = BottomSheetControllerProvider._();
final class BottomSheetControllerProvider
extends $NotifierProvider<BottomSheetController, Sheet?> {
BottomSheetControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bottomSheetControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bottomSheetControllerHash();
@$internal
@override
BottomSheetController create() => BottomSheetController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Sheet? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Sheet?>(value),
);
}
}
String _$bottomSheetControllerHash() =>
r'54112ccef72ad7777c746b9f2dc02da1e383c127';
abstract class _$BottomSheetController extends $Notifier<Sheet?> {
Sheet? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<Sheet?, Sheet?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Sheet?, Sheet?>,
Sheet?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,40 @@
/*
* 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 'package:flutter/widgets.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'overlay.g.dart';
@Riverpod(keepAlive: true)
class OverlayController extends _$OverlayController {
@override
WidgetBuilder? build() {
return null;
}
// ignore: use_setters_to_change_properties api decision
void show(WidgetBuilder builder) {
state = builder;
}
void dismiss() {
state = null;
}
}
@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'overlay.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(OverlayController)
final overlayControllerProvider = OverlayControllerProvider._();
final class OverlayControllerProvider
extends $NotifierProvider<OverlayController, WidgetBuilder?> {
OverlayControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'overlayControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$overlayControllerHash();
@$internal
@override
OverlayController create() => OverlayController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(WidgetBuilder? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<WidgetBuilder?>(value),
);
}
}
String _$overlayControllerHash() => r'd0cd7c4cf867397f10f801ec2f7733be56520fb3';
abstract class _$OverlayController extends $Notifier<WidgetBuilder?> {
WidgetBuilder? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<WidgetBuilder?, WidgetBuilder?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<WidgetBuilder?, WidgetBuilder?>,
WidgetBuilder?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,56 @@
/*
* 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:typed_data';
import 'dart:ui';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:weblibre/domain/entities/equatable_image.dart';
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
class BrowserIcon with FastEquatable {
final EquatableImage image;
final Color? dominantColor;
final IconSource source;
static Future<BrowserIcon> fromBytes(
Uint8List bytes, {
required Color? dominantColor,
required IconSource source,
}) async {
final image = await tryDecodeImage(bytes);
return BrowserIcon(
image: image!,
dominantColor: dominantColor,
source: source,
);
}
BrowserIcon({
required this.image,
required this.dominantColor,
required this.source,
});
@override
List<Object?> get hashParameters => [image, dominantColor, source];
}
@@ -0,0 +1,52 @@
/*
* 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 'package:fast_equatable/fast_equatable.dart';
class FindResultState with FastEquatable {
final String? lastSearchText;
final int activeMatchOrdinal;
final int numberOfMatches;
final bool isDoneCounting;
bool get hasMatches => numberOfMatches > 0;
FindResultState({
required this.lastSearchText,
required this.activeMatchOrdinal,
required this.numberOfMatches,
required this.isDoneCounting,
});
factory FindResultState.$default() => FindResultState(
lastSearchText: null,
activeMatchOrdinal: -1,
numberOfMatches: 0,
isDoneCounting: false,
);
@override
List<Object?> get hashParameters => [
lastSearchText,
activeMatchOrdinal,
numberOfMatches,
isDoneCounting,
];
}
@@ -0,0 +1,60 @@
/*
* 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 'package:fast_equatable/fast_equatable.dart';
class HistoryItem with FastEquatable {
final Uri url;
final String title;
HistoryItem({required this.url, required this.title});
@override
List<Object?> get hashParameters => [url, title];
}
class HistoryState with FastEquatable {
final List<HistoryItem> items;
final int currentIndex;
final bool canGoBack;
final bool canGoForward;
HistoryState({
required this.items,
required this.currentIndex,
required this.canGoBack,
required this.canGoForward,
});
factory HistoryState.$default() => HistoryState(
items: const [],
currentIndex: 0,
canGoBack: false,
canGoForward: false,
);
@override
List<Object?> get hashParameters => [
items,
currentIndex,
canGoBack,
canGoForward,
];
}
@@ -0,0 +1,34 @@
/*
* 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 'package:fast_equatable/fast_equatable.dart';
class ReaderableState with FastEquatable {
final bool readerable;
final bool active;
ReaderableState({required this.readerable, required this.active});
factory ReaderableState.$default() =>
ReaderableState(readerable: false, active: false);
@override
List<Object?> get hashParameters => [readerable, active];
}
@@ -0,0 +1,38 @@
/*
* 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 'package:fast_equatable/fast_equatable.dart';
class SecurityState with FastEquatable {
final bool secure;
final String host;
final String issuer;
SecurityState({
required this.secure,
required this.host,
required this.issuer,
});
factory SecurityState.$default() =>
SecurityState(secure: false, host: "", issuer: "");
@override
List<Object?> get hashParameters => [secure, host, issuer];
}
@@ -0,0 +1,139 @@
/*
* 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 'package:copy_with_extension/copy_with_extension.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/data/models/web_page_info.dart';
import 'package:weblibre/domain/entities/equatable_image.dart';
import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/find_result.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/history.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/security.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/translation.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
part 'tab.g.dart';
@CopyWith()
class TabState extends WebPageInfo {
static final defaultUrl = Uri.parse('about:blank');
@CopyWithField(immutable: true)
final String id;
final String? parentId;
final String? contextId;
@override
String get title => super.title!;
String get titleOrAuthority => (title.isNotEmpty) ? title : url.authority;
final EquatableImage? icon;
@override
BrowserIcon? get favicon => icon.mapNotNull(
(icon) => BrowserIcon(
image: icon,
dominantColor: null,
source: IconSource.memory,
),
);
final EquatableImage? thumbnail;
final int progress;
final TabMode tabMode;
String? get isolationContextId => tabMode.isolationContextId;
final bool isFullScreen;
final bool isLoading;
final bool showToolbarAsExpanded;
bool get isFinishedLoading => !isLoading && progress == 100;
final SecurityState securityInfoState;
final HistoryState historyState;
final ReaderableState readerableState;
final FindResultState findResultState;
final TranslationState translationState;
TabState({
required this.id,
required this.parentId,
required this.contextId,
required super.url,
required String title,
required this.icon,
required this.thumbnail,
required this.progress,
this.tabMode = TabMode.regular,
required this.isFullScreen,
required this.isLoading,
required this.showToolbarAsExpanded,
required this.securityInfoState,
required this.historyState,
required this.readerableState,
required this.findResultState,
required this.translationState,
}) : super(title: title.trim());
factory TabState.$default(String tabId) => TabState(
id: tabId,
parentId: null,
contextId: null,
url: defaultUrl,
title: "",
icon: null,
thumbnail: null,
progress: 0,
isFullScreen: false,
isLoading: false,
showToolbarAsExpanded: false,
securityInfoState: SecurityState.$default(),
historyState: HistoryState.$default(),
readerableState: ReaderableState.$default(),
findResultState: FindResultState.$default(),
translationState: TranslationState.$default(),
);
@override
List<Object?> get hashParameters => [
...super.hashParameters,
id,
parentId,
contextId,
icon,
thumbnail,
progress,
tabMode,
isFullScreen,
isLoading,
showToolbarAsExpanded,
securityInfoState,
historyState,
readerableState,
findResultState,
translationState,
];
}
@@ -0,0 +1,243 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tab.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$TabStateCWProxy {
TabState parentId(String? parentId);
TabState contextId(String? contextId);
TabState url(Uri url);
TabState title(String title);
TabState icon(EquatableImage? icon);
TabState thumbnail(EquatableImage? thumbnail);
TabState progress(int progress);
TabState tabMode(TabMode tabMode);
TabState isFullScreen(bool isFullScreen);
TabState isLoading(bool isLoading);
TabState showToolbarAsExpanded(bool showToolbarAsExpanded);
TabState securityInfoState(SecurityState securityInfoState);
TabState historyState(HistoryState historyState);
TabState readerableState(ReaderableState readerableState);
TabState findResultState(FindResultState findResultState);
TabState translationState(TranslationState translationState);
/// 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 `TabState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// TabState(...).copyWith(id: 12, name: "My name")
/// ```
TabState call({
String? parentId,
String? contextId,
Uri url,
String title,
EquatableImage? icon,
EquatableImage? thumbnail,
int progress,
TabMode tabMode,
bool isFullScreen,
bool isLoading,
bool showToolbarAsExpanded,
SecurityState securityInfoState,
HistoryState historyState,
ReaderableState readerableState,
FindResultState findResultState,
TranslationState translationState,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfTabState.copyWith(...)` or call `instanceOfTabState.copyWith.fieldName(value)` for a single field.
class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
const _$TabStateCWProxyImpl(this._value);
final TabState _value;
@override
TabState parentId(String? parentId) => call(parentId: parentId);
@override
TabState contextId(String? contextId) => call(contextId: contextId);
@override
TabState url(Uri url) => call(url: url);
@override
TabState title(String title) => call(title: title);
@override
TabState icon(EquatableImage? icon) => call(icon: icon);
@override
TabState thumbnail(EquatableImage? thumbnail) => call(thumbnail: thumbnail);
@override
TabState progress(int progress) => call(progress: progress);
@override
TabState tabMode(TabMode tabMode) => call(tabMode: tabMode);
@override
TabState isFullScreen(bool isFullScreen) => call(isFullScreen: isFullScreen);
@override
TabState isLoading(bool isLoading) => call(isLoading: isLoading);
@override
TabState showToolbarAsExpanded(bool showToolbarAsExpanded) =>
call(showToolbarAsExpanded: showToolbarAsExpanded);
@override
TabState securityInfoState(SecurityState securityInfoState) =>
call(securityInfoState: securityInfoState);
@override
TabState historyState(HistoryState historyState) =>
call(historyState: historyState);
@override
TabState readerableState(ReaderableState readerableState) =>
call(readerableState: readerableState);
@override
TabState findResultState(FindResultState findResultState) =>
call(findResultState: findResultState);
@override
TabState translationState(TranslationState translationState) =>
call(translationState: translationState);
@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 `TabState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// TabState(...).copyWith(id: 12, name: "My name")
/// ```
TabState call({
Object? parentId = const $CopyWithPlaceholder(),
Object? contextId = const $CopyWithPlaceholder(),
Object? url = const $CopyWithPlaceholder(),
Object? title = const $CopyWithPlaceholder(),
Object? icon = const $CopyWithPlaceholder(),
Object? thumbnail = const $CopyWithPlaceholder(),
Object? progress = const $CopyWithPlaceholder(),
Object? tabMode = const $CopyWithPlaceholder(),
Object? isFullScreen = const $CopyWithPlaceholder(),
Object? isLoading = const $CopyWithPlaceholder(),
Object? showToolbarAsExpanded = const $CopyWithPlaceholder(),
Object? securityInfoState = const $CopyWithPlaceholder(),
Object? historyState = const $CopyWithPlaceholder(),
Object? readerableState = const $CopyWithPlaceholder(),
Object? findResultState = const $CopyWithPlaceholder(),
Object? translationState = const $CopyWithPlaceholder(),
}) {
return TabState(
id: _value.id,
parentId: parentId == const $CopyWithPlaceholder()
? _value.parentId
// ignore: cast_nullable_to_non_nullable
: parentId as String?,
contextId: contextId == const $CopyWithPlaceholder()
? _value.contextId
// ignore: cast_nullable_to_non_nullable
: contextId as String?,
url: url == const $CopyWithPlaceholder() || url == null
? _value.url
// ignore: cast_nullable_to_non_nullable
: url as Uri,
title: title == const $CopyWithPlaceholder() || title == null
? _value.title
// ignore: cast_nullable_to_non_nullable
: title as String,
icon: icon == const $CopyWithPlaceholder()
? _value.icon
// ignore: cast_nullable_to_non_nullable
: icon as EquatableImage?,
thumbnail: thumbnail == const $CopyWithPlaceholder()
? _value.thumbnail
// ignore: cast_nullable_to_non_nullable
: thumbnail as EquatableImage?,
progress: progress == const $CopyWithPlaceholder() || progress == null
? _value.progress
// ignore: cast_nullable_to_non_nullable
: progress as int,
tabMode: tabMode == const $CopyWithPlaceholder() || tabMode == null
? _value.tabMode
// ignore: cast_nullable_to_non_nullable
: tabMode as TabMode,
isFullScreen:
isFullScreen == const $CopyWithPlaceholder() || isFullScreen == null
? _value.isFullScreen
// ignore: cast_nullable_to_non_nullable
: isFullScreen as bool,
isLoading: isLoading == const $CopyWithPlaceholder() || isLoading == null
? _value.isLoading
// ignore: cast_nullable_to_non_nullable
: isLoading as bool,
showToolbarAsExpanded:
showToolbarAsExpanded == const $CopyWithPlaceholder() ||
showToolbarAsExpanded == null
? _value.showToolbarAsExpanded
// ignore: cast_nullable_to_non_nullable
: showToolbarAsExpanded as bool,
securityInfoState:
securityInfoState == const $CopyWithPlaceholder() ||
securityInfoState == null
? _value.securityInfoState
// ignore: cast_nullable_to_non_nullable
: securityInfoState as SecurityState,
historyState:
historyState == const $CopyWithPlaceholder() || historyState == null
? _value.historyState
// ignore: cast_nullable_to_non_nullable
: historyState as HistoryState,
readerableState:
readerableState == const $CopyWithPlaceholder() ||
readerableState == null
? _value.readerableState
// ignore: cast_nullable_to_non_nullable
: readerableState as ReaderableState,
findResultState:
findResultState == const $CopyWithPlaceholder() ||
findResultState == null
? _value.findResultState
// ignore: cast_nullable_to_non_nullable
: findResultState as FindResultState,
translationState:
translationState == const $CopyWithPlaceholder() ||
translationState == null
? _value.translationState
// ignore: cast_nullable_to_non_nullable
: translationState as TranslationState,
);
}
}
extension $TabStateCopyWith on TabState {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfTabState.copyWith(...)` or `instanceOfTabState.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$TabStateCWProxy get copyWith => _$TabStateCWProxyImpl(this);
}
@@ -0,0 +1,84 @@
/*
* 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 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
class TranslationState with FastEquatable {
final bool isTranslated;
final bool isTranslateProcessing;
final bool isOfferTranslate;
final bool isExpectedTranslate;
final String? detectedLanguageCode;
final String? userPreferredLanguageCode;
final String? requestedFromLanguage;
final String? requestedToLanguage;
final String? translationErrorName;
final bool? displayError;
TranslationState({
required this.isTranslated,
required this.isTranslateProcessing,
required this.isOfferTranslate,
required this.isExpectedTranslate,
this.detectedLanguageCode,
this.userPreferredLanguageCode,
this.requestedFromLanguage,
this.requestedToLanguage,
this.translationErrorName,
this.displayError,
});
factory TranslationState.$default() => TranslationState(
isTranslated: false,
isTranslateProcessing: false,
isOfferTranslate: false,
isExpectedTranslate: false,
);
factory TranslationState.fromData(TabTranslationStateData data) =>
TranslationState(
isTranslated: data.isTranslated,
isTranslateProcessing: data.isTranslateProcessing,
isOfferTranslate: data.isOfferTranslate,
isExpectedTranslate: data.isExpectedTranslate,
detectedLanguageCode: data.detectedLanguageCode,
userPreferredLanguageCode: data.userPreferredLanguageCode,
requestedFromLanguage: data.requestedFromLanguage,
requestedToLanguage: data.requestedToLanguage,
translationErrorName: data.translationErrorName,
displayError: data.displayError,
);
bool get hasError => translationErrorName != null && (displayError ?? true);
@override
List<Object?> get hashParameters => [
isTranslated,
isTranslateProcessing,
isOfferTranslate,
isExpectedTranslate,
detectedLanguageCode,
userPreferredLanguageCode,
requestedFromLanguage,
requestedToLanguage,
translationErrorName,
displayError,
];
}
@@ -0,0 +1,60 @@
/*
* 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:ui';
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/domain/entities/equatable_image.dart';
part 'web_extension.g.dart';
@CopyWith()
class WebExtensionState with FastEquatable {
String extensionId;
String? title;
bool enabled;
String? badgeText;
Color? badgeTextColor;
Color? badgeBackgroundColor;
final EquatableImage? icon;
WebExtensionState({
required this.extensionId,
required this.enabled,
this.title,
this.badgeText,
this.badgeTextColor,
this.badgeBackgroundColor,
this.icon,
});
@override
List<Object?> get hashParameters => [
extensionId,
title,
enabled,
badgeText,
badgeTextColor,
badgeBackgroundColor,
icon,
];
}
@@ -0,0 +1,130 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'web_extension.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$WebExtensionStateCWProxy {
WebExtensionState extensionId(String extensionId);
WebExtensionState enabled(bool enabled);
WebExtensionState title(String? title);
WebExtensionState badgeText(String? badgeText);
WebExtensionState badgeTextColor(Color? badgeTextColor);
WebExtensionState badgeBackgroundColor(Color? badgeBackgroundColor);
WebExtensionState icon(EquatableImage? icon);
/// 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 `WebExtensionState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// WebExtensionState(...).copyWith(id: 12, name: "My name")
/// ```
WebExtensionState call({
String extensionId,
bool enabled,
String? title,
String? badgeText,
Color? badgeTextColor,
Color? badgeBackgroundColor,
EquatableImage? icon,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfWebExtensionState.copyWith(...)` or call `instanceOfWebExtensionState.copyWith.fieldName(value)` for a single field.
class _$WebExtensionStateCWProxyImpl implements _$WebExtensionStateCWProxy {
const _$WebExtensionStateCWProxyImpl(this._value);
final WebExtensionState _value;
@override
WebExtensionState extensionId(String extensionId) =>
call(extensionId: extensionId);
@override
WebExtensionState enabled(bool enabled) => call(enabled: enabled);
@override
WebExtensionState title(String? title) => call(title: title);
@override
WebExtensionState badgeText(String? badgeText) => call(badgeText: badgeText);
@override
WebExtensionState badgeTextColor(Color? badgeTextColor) =>
call(badgeTextColor: badgeTextColor);
@override
WebExtensionState badgeBackgroundColor(Color? badgeBackgroundColor) =>
call(badgeBackgroundColor: badgeBackgroundColor);
@override
WebExtensionState icon(EquatableImage? icon) => call(icon: icon);
@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 `WebExtensionState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// WebExtensionState(...).copyWith(id: 12, name: "My name")
/// ```
WebExtensionState call({
Object? extensionId = const $CopyWithPlaceholder(),
Object? enabled = const $CopyWithPlaceholder(),
Object? title = const $CopyWithPlaceholder(),
Object? badgeText = const $CopyWithPlaceholder(),
Object? badgeTextColor = const $CopyWithPlaceholder(),
Object? badgeBackgroundColor = const $CopyWithPlaceholder(),
Object? icon = const $CopyWithPlaceholder(),
}) {
return WebExtensionState(
extensionId:
extensionId == const $CopyWithPlaceholder() || extensionId == null
? _value.extensionId
// ignore: cast_nullable_to_non_nullable
: extensionId as String,
enabled: enabled == const $CopyWithPlaceholder() || enabled == null
? _value.enabled
// ignore: cast_nullable_to_non_nullable
: enabled as bool,
title: title == const $CopyWithPlaceholder()
? _value.title
// ignore: cast_nullable_to_non_nullable
: title as String?,
badgeText: badgeText == const $CopyWithPlaceholder()
? _value.badgeText
// ignore: cast_nullable_to_non_nullable
: badgeText as String?,
badgeTextColor: badgeTextColor == const $CopyWithPlaceholder()
? _value.badgeTextColor
// ignore: cast_nullable_to_non_nullable
: badgeTextColor as Color?,
badgeBackgroundColor: badgeBackgroundColor == const $CopyWithPlaceholder()
? _value.badgeBackgroundColor
// ignore: cast_nullable_to_non_nullable
: badgeBackgroundColor as Color?,
icon: icon == const $CopyWithPlaceholder()
? _value.icon
// ignore: cast_nullable_to_non_nullable
: icon as EquatableImage?,
);
}
}
extension $WebExtensionStateCopyWith on WebExtensionState {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfWebExtensionState.copyWith(...)` or `instanceOfWebExtensionState.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$WebExtensionStateCWProxy get copyWith =>
_$WebExtensionStateCWProxyImpl(this);
}
@@ -0,0 +1,48 @@
/*
* 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 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
sealed class TabContainerSelection {
const TabContainerSelection();
const factory TabContainerSelection.useSelected() =
UseSelectedContainerTabSelection;
const factory TabContainerSelection.unassigned() =
UnassignedContainerTabSelection;
const factory TabContainerSelection.specific(ContainerData container) =
SpecificContainerTabSelection;
}
final class UseSelectedContainerTabSelection extends TabContainerSelection {
const UseSelectedContainerTabSelection();
}
final class UnassignedContainerTabSelection extends TabContainerSelection {
const UnassignedContainerTabSelection();
}
final class SpecificContainerTabSelection extends TabContainerSelection {
final ContainerData container;
const SpecificContainerTabSelection(this.container);
}
@@ -0,0 +1,303 @@
/*
* 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:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/providers/router.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.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/find_in_page/domain/repositories/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'providers.g.dart';
@Riverpod(keepAlive: true)
GeckoSelectionActionService selectionActionService(Ref ref) {
final service = GeckoSelectionActionService.setUp();
unawaited(
service.setActions([
NewTabAction((text) async {
if (ref.mounted) {
final router = await ref.read(routerProvider.future);
if (ref.mounted) {
final settings = ref.read(generalSettingsWithDefaultsProvider);
final selectedTabState = ref.read(
tabStatesProvider,
)[ref.read(selectedTabProvider)];
final selectedTabType = selectedTabState?.tabMode.toTabType();
final route = SearchRoute(
tabType:
selectedTabType ?? settings.effectiveDefaultCreateTabType,
searchText: text,
);
await router.push(route.location);
}
}
}),
DefaultSearchAction((text) async {
if (ref.mounted) {
final defaultSearchBang = await ref.read(
defaultSearchBangDataProvider.future,
);
if (ref.mounted && defaultSearchBang != null) {
final currentTab = ref.read(
tabStatesProvider,
)[ref.read(selectedTabProvider)];
final tabMode =
currentTab?.tabMode ??
TabMode.fromTabType(
ref
.read(generalSettingsWithDefaultsProvider)
.effectiveDefaultCreateTabType,
);
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: defaultSearchBang.getTemplateUrl(text),
parentId: currentTab?.id,
tabMode: tabMode,
selectTab: true,
);
} else {
logger.e('No default search bang found');
}
}
}),
FindInPageAction((text) async {
if (ref.mounted) {
final tabId = ref.read(selectedTabProvider);
if (tabId != null) {
await ref
.read(findInPageRepositoryProvider(tabId).notifier)
.findAll(text);
}
}
}),
ShareAction((text) async {
await SharePlus.instance.share(ShareParams(text: text));
}),
CallAction((text) async {
final uri = Uri.tryParse('tel:${text.replaceAll(' ', '')}');
if (uri != null) {
final canLaunch = await canLaunchUrl(uri);
if (canLaunch) {
await launchUrl(uri);
}
}
}),
EmailAction((text) async {
final uri = Uri.tryParse('mailto:$text');
if (uri != null) {
final canLaunch = await canLaunchUrl(uri);
if (canLaunch) {
await launchUrl(uri);
}
}
}),
]),
);
return service;
}
@Riverpod(keepAlive: true)
GeckoEventService eventService(Ref ref) {
final service = GeckoEventService.setUp();
ref.onDispose(() async {
await service.dispose();
});
return service;
}
@Riverpod(keepAlive: true)
GeckoAddonService addonService(Ref ref) {
final service = GeckoAddonService.setUp();
ref.onDispose(() async {
await service.dispose();
});
return service;
}
@Riverpod(keepAlive: true)
GeckoTabContentService tabContentService(Ref ref) {
final service = GeckoTabContentService.setUp();
ref.onDispose(() async {
await service.dispose();
});
return service;
}
@Riverpod(keepAlive: true)
GeckoSuggestionsService engineSuggestionsService(Ref ref) {
final service = GeckoSuggestionsService.setUp();
ref.onDispose(() async {
await service.dispose();
});
return service;
}
@Riverpod(keepAlive: true)
GeckoViewportService viewportService(Ref ref) {
final service = GeckoViewportService();
service.setUp();
ref.onDispose(() async {
await service.dispose();
});
return service;
}
@Riverpod(keepAlive: true)
class EngineReadyState extends _$EngineReadyState {
@override
bool build() {
final eventService = ref.watch(eventServiceProvider);
final currentState =
eventService.engineReadyStateEvents.valueOrNull ?? false;
if (!currentState) {
unawaited(
eventService.engineReadyStateEvents
.firstWhere((value) => value == true)
.timeout(
const Duration(seconds: 3),
onTimeout: () {
logger.w('Waiting for engine ready state timed out');
return true;
},
)
.whenComplete(() {
if (ref.mounted) {
state = true;
}
}),
);
}
final sub = eventService.engineReadyStateEvents.listen((value) {
if (ref.mounted) {
state = value;
}
});
ref.onDispose(() async {
await sub.cancel();
});
return currentState;
}
}
/// Stream of ML model progress events
@Riverpod(keepAlive: true)
Stream<MlProgressData> mlProgressEvents(Ref ref) {
final service = ref.watch(eventServiceProvider);
return service.mlProgressEvents;
}
/// Translation engine state (browser-level: supported languages, engine availability)
@Riverpod(keepAlive: true)
class TranslationEngineState extends _$TranslationEngineState {
@override
TranslationEngineStateData? build() {
final eventService = ref.watch(eventServiceProvider);
final sub = eventService.translationEngineEvents.listen((value) {
if (ref.mounted) {
state = value;
}
});
ref.onDispose(() async {
await sub.cancel();
});
return eventService.translationEngineEvents.valueOrNull;
}
}
/// Tracks active ML model downloads
@Riverpod()
class MlDownloadState extends _$MlDownloadState {
Timer? _clearTimer;
@override
MlProgressData? build() {
ref.listen(mlProgressEventsProvider, (previous, next) {
next.whenData((progress) {
if (!ref.mounted) return;
if (progress.type == MlProgressType.downloading) {
if (progress.status == MlProgressStatus.done) {
// Keep showing for 2 seconds after completion
_clearTimer?.cancel();
_clearTimer = Timer(const Duration(seconds: 2), () {
if (ref.mounted && state != null && state!.id == progress.id) {
state = null;
}
});
} else {
state = progress;
}
}
});
});
ref.onDispose(() {
_clearTimer?.cancel();
_clearTimer = null;
});
return null;
}
void clear() {
_clearTimer?.cancel();
_clearTimer = null;
state = null;
}
}
@@ -0,0 +1,514 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(selectionActionService)
final selectionActionServiceProvider = SelectionActionServiceProvider._();
final class SelectionActionServiceProvider
extends
$FunctionalProvider<
GeckoSelectionActionService,
GeckoSelectionActionService,
GeckoSelectionActionService
>
with $Provider<GeckoSelectionActionService> {
SelectionActionServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectionActionServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectionActionServiceHash();
@$internal
@override
$ProviderElement<GeckoSelectionActionService> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
GeckoSelectionActionService create(Ref ref) {
return selectionActionService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GeckoSelectionActionService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GeckoSelectionActionService>(value),
);
}
}
String _$selectionActionServiceHash() =>
r'5faf8c13c414406dfc2eb55fdd372677dbccdf8c';
@ProviderFor(eventService)
final eventServiceProvider = EventServiceProvider._();
final class EventServiceProvider
extends
$FunctionalProvider<
GeckoEventService,
GeckoEventService,
GeckoEventService
>
with $Provider<GeckoEventService> {
EventServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'eventServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$eventServiceHash();
@$internal
@override
$ProviderElement<GeckoEventService> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
GeckoEventService create(Ref ref) {
return eventService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GeckoEventService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GeckoEventService>(value),
);
}
}
String _$eventServiceHash() => r'3a297348fadda05dc60433d7ce8f662b2ff62c26';
@ProviderFor(addonService)
final addonServiceProvider = AddonServiceProvider._();
final class AddonServiceProvider
extends
$FunctionalProvider<
GeckoAddonService,
GeckoAddonService,
GeckoAddonService
>
with $Provider<GeckoAddonService> {
AddonServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'addonServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$addonServiceHash();
@$internal
@override
$ProviderElement<GeckoAddonService> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
GeckoAddonService create(Ref ref) {
return addonService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GeckoAddonService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GeckoAddonService>(value),
);
}
}
String _$addonServiceHash() => r'30fedb35c68943159246df79b5f1b62a25767fa0';
@ProviderFor(tabContentService)
final tabContentServiceProvider = TabContentServiceProvider._();
final class TabContentServiceProvider
extends
$FunctionalProvider<
GeckoTabContentService,
GeckoTabContentService,
GeckoTabContentService
>
with $Provider<GeckoTabContentService> {
TabContentServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabContentServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabContentServiceHash();
@$internal
@override
$ProviderElement<GeckoTabContentService> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
GeckoTabContentService create(Ref ref) {
return tabContentService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GeckoTabContentService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GeckoTabContentService>(value),
);
}
}
String _$tabContentServiceHash() => r'12d8322c37ded4ad3344af327d884bbf7f089594';
@ProviderFor(engineSuggestionsService)
final engineSuggestionsServiceProvider = EngineSuggestionsServiceProvider._();
final class EngineSuggestionsServiceProvider
extends
$FunctionalProvider<
GeckoSuggestionsService,
GeckoSuggestionsService,
GeckoSuggestionsService
>
with $Provider<GeckoSuggestionsService> {
EngineSuggestionsServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'engineSuggestionsServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$engineSuggestionsServiceHash();
@$internal
@override
$ProviderElement<GeckoSuggestionsService> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
GeckoSuggestionsService create(Ref ref) {
return engineSuggestionsService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GeckoSuggestionsService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GeckoSuggestionsService>(value),
);
}
}
String _$engineSuggestionsServiceHash() =>
r'1ec1192f0c5c86cecc7ad448ee2b039f7a48e32b';
@ProviderFor(viewportService)
final viewportServiceProvider = ViewportServiceProvider._();
final class ViewportServiceProvider
extends
$FunctionalProvider<
GeckoViewportService,
GeckoViewportService,
GeckoViewportService
>
with $Provider<GeckoViewportService> {
ViewportServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'viewportServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$viewportServiceHash();
@$internal
@override
$ProviderElement<GeckoViewportService> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
GeckoViewportService create(Ref ref) {
return viewportService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GeckoViewportService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GeckoViewportService>(value),
);
}
}
String _$viewportServiceHash() => r'bab39db3180bb6a1cf8c055966b7f5910b41b424';
@ProviderFor(EngineReadyState)
final engineReadyStateProvider = EngineReadyStateProvider._();
final class EngineReadyStateProvider
extends $NotifierProvider<EngineReadyState, bool> {
EngineReadyStateProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'engineReadyStateProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$engineReadyStateHash();
@$internal
@override
EngineReadyState create() => EngineReadyState();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$engineReadyStateHash() => r'ab3fe385fbda4d7d8acaf8f5428b3b48b75f3f46';
abstract class _$EngineReadyState extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
/// Stream of ML model progress events
@ProviderFor(mlProgressEvents)
final mlProgressEventsProvider = MlProgressEventsProvider._();
/// Stream of ML model progress events
final class MlProgressEventsProvider
extends
$FunctionalProvider<
AsyncValue<MlProgressData>,
MlProgressData,
Stream<MlProgressData>
>
with $FutureModifier<MlProgressData>, $StreamProvider<MlProgressData> {
/// Stream of ML model progress events
MlProgressEventsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'mlProgressEventsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$mlProgressEventsHash();
@$internal
@override
$StreamProviderElement<MlProgressData> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<MlProgressData> create(Ref ref) {
return mlProgressEvents(ref);
}
}
String _$mlProgressEventsHash() => r'41c1e6aece7f9ee2bebe5d189c6bec753c2fefc8';
/// Translation engine state (browser-level: supported languages, engine availability)
@ProviderFor(TranslationEngineState)
final translationEngineStateProvider = TranslationEngineStateProvider._();
/// Translation engine state (browser-level: supported languages, engine availability)
final class TranslationEngineStateProvider
extends
$NotifierProvider<TranslationEngineState, TranslationEngineStateData?> {
/// Translation engine state (browser-level: supported languages, engine availability)
TranslationEngineStateProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'translationEngineStateProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$translationEngineStateHash();
@$internal
@override
TranslationEngineState create() => TranslationEngineState();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(TranslationEngineStateData? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<TranslationEngineStateData?>(value),
);
}
}
String _$translationEngineStateHash() =>
r'04592e18715b0e237e9bc027287a880a4bb023da';
/// Translation engine state (browser-level: supported languages, engine availability)
abstract class _$TranslationEngineState
extends $Notifier<TranslationEngineStateData?> {
TranslationEngineStateData? build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<TranslationEngineStateData?, TranslationEngineStateData?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
TranslationEngineStateData?,
TranslationEngineStateData?
>,
TranslationEngineStateData?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
/// Tracks active ML model downloads
@ProviderFor(MlDownloadState)
final mlDownloadStateProvider = MlDownloadStateProvider._();
/// Tracks active ML model downloads
final class MlDownloadStateProvider
extends $NotifierProvider<MlDownloadState, MlProgressData?> {
/// Tracks active ML model downloads
MlDownloadStateProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'mlDownloadStateProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$mlDownloadStateHash();
@$internal
@override
MlDownloadState create() => MlDownloadState();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(MlProgressData? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<MlProgressData?>(value),
);
}
}
String _$mlDownloadStateHash() => r'4b943f0a950bf63c9a1b628d4104fdd6fa0d8d07';
/// Tracks active ML model downloads
abstract class _$MlDownloadState extends $Notifier<MlProgressData?> {
MlProgressData? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<MlProgressData?, MlProgressData?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<MlProgressData?, MlProgressData?>,
MlProgressData?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,40 @@
/*
* 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 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'browser_extension.g.dart';
@Riverpod(keepAlive: true)
GeckoBrowserExtensionService browserExtensionService(Ref ref) {
final service = GeckoBrowserExtensionService.setUp();
ref.onDispose(() {
service.dispose();
});
return service;
}
@Riverpod()
Stream<String> feedRequested(Ref ref) {
final service = ref.watch(browserExtensionServiceProvider);
return service.feedRequested;
}
@@ -0,0 +1,91 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'browser_extension.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(browserExtensionService)
final browserExtensionServiceProvider = BrowserExtensionServiceProvider._();
final class BrowserExtensionServiceProvider
extends
$FunctionalProvider<
GeckoBrowserExtensionService,
GeckoBrowserExtensionService,
GeckoBrowserExtensionService
>
with $Provider<GeckoBrowserExtensionService> {
BrowserExtensionServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'browserExtensionServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$browserExtensionServiceHash();
@$internal
@override
$ProviderElement<GeckoBrowserExtensionService> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
GeckoBrowserExtensionService create(Ref ref) {
return browserExtensionService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GeckoBrowserExtensionService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GeckoBrowserExtensionService>(value),
);
}
}
String _$browserExtensionServiceHash() =>
r'c3f67763e0abb10039b407fced6e175e5ec7c6c3';
@ProviderFor(feedRequested)
final feedRequestedProvider = FeedRequestedProvider._();
final class FeedRequestedProvider
extends $FunctionalProvider<AsyncValue<String>, String, Stream<String>>
with $FutureModifier<String>, $StreamProvider<String> {
FeedRequestedProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'feedRequestedProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$feedRequestedHash();
@$internal
@override
$StreamProviderElement<String> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<String> create(Ref ref) {
return feedRequested(ref);
}
}
String _$feedRequestedHash() => r'4f179d0878072a77a87422ff6afdac53a3d57c04';
@@ -0,0 +1,48 @@
/*
* 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 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
part 'desktop_mode.g.dart';
@Riverpod(keepAlive: true)
class DesktopMode extends _$DesktopMode {
// ignore: use_setters_to_change_properties
void enabled(bool value) {
state = value;
}
void toggle() {
state = !state;
}
@override
bool build(String tabId) {
listenSelf((previous, next) async {
if (previous != null) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.requestDesktopSite(next);
}
});
return false;
}
}
@@ -0,0 +1,99 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'desktop_mode.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(DesktopMode)
final desktopModeProvider = DesktopModeFamily._();
final class DesktopModeProvider extends $NotifierProvider<DesktopMode, bool> {
DesktopModeProvider._({
required DesktopModeFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'desktopModeProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$desktopModeHash();
@override
String toString() {
return r'desktopModeProvider'
''
'($argument)';
}
@$internal
@override
DesktopMode create() => DesktopMode();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
@override
bool operator ==(Object other) {
return other is DesktopModeProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$desktopModeHash() => r'18550009e95a23ff82d30206ce81daa859c57c26';
final class DesktopModeFamily extends $Family
with $ClassFamilyOverride<DesktopMode, bool, bool, bool, String> {
DesktopModeFamily._()
: super(
retry: null,
name: r'desktopModeProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: false,
);
DesktopModeProvider call(String tabId) =>
DesktopModeProvider._(argument: tabId, from: this);
@override
String toString() => r'desktopModeProvider';
}
abstract class _$DesktopMode extends $Notifier<bool> {
late final _$args = ref.$arg as String;
String get tabId => _$args;
bool build(String tabId);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@@ -0,0 +1,69 @@
/*
* 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 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
part 'selected_tab.g.dart';
@Riverpod(keepAlive: true)
class SelectedTab extends _$SelectedTab {
@override
String? build() {
final eventSerivce = ref.watch(eventServiceProvider);
ref.listen(
fireImmediately: true,
engineReadyStateProvider,
(previous, next) async {
if (next) {
await GeckoTabService().syncEvents(onSelectedTabChange: true);
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to engineReadyStateProvider',
error: error,
stackTrace: stackTrace,
);
},
);
final selectedTabSub = eventSerivce.selectedTabEvents.listen(
(tabId) {
state = tabId;
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in selected tab events',
error: error,
stackTrace: stackTrace,
);
},
);
ref.onDispose(() async {
await selectedTabSub.cancel();
});
return null;
}
}
@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'selected_tab.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(SelectedTab)
final selectedTabProvider = SelectedTabProvider._();
final class SelectedTabProvider
extends $NotifierProvider<SelectedTab, String?> {
SelectedTabProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedTabProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedTabHash();
@$internal
@override
SelectedTab create() => SelectedTab();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$selectedTabHash() => r'5caa359376e9103767e2d2e94c548d53cba79d0f';
abstract class _$SelectedTab extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,74 @@
/*
* 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 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
part 'tab_list.g.dart';
@Riverpod(keepAlive: true)
class TabList extends _$TabList {
@override
EquatableValue<List<String>> build() {
final eventService = ref.watch(eventServiceProvider);
ref.listen(
fireImmediately: true,
engineReadyStateProvider,
(previous, next) async {
if (next) {
await GeckoTabService().syncEvents(onTabListChange: true);
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to eventServiceProvider',
error: error,
stackTrace: stackTrace,
);
},
);
final tabListSub = eventService.tabListEvents.listen(
(tabs) {
final equatableTabs = EquatableValue(tabs);
if (equatableTabs != state) {
state = equatableTabs;
}
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in tab list events',
error: error,
stackTrace: stackTrace,
);
},
);
ref.onDispose(() async {
await tabListSub.cancel();
});
return EquatableValue([]);
}
}
@@ -0,0 +1,67 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tab_list.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(TabList)
final tabListProvider = TabListProvider._();
final class TabListProvider
extends $NotifierProvider<TabList, EquatableValue<List<String>>> {
TabListProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabListProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabListHash();
@$internal
@override
TabList create() => TabList();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EquatableValue<List<String>> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<EquatableValue<List<String>>>(value),
);
}
}
String _$tabListHash() => r'88c2598a4d586fee9e3d0f1b418cf88ea5c7da75';
abstract class _$TabList extends $Notifier<EquatableValue<List<String>>> {
EquatableValue<List<String>> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<EquatableValue<List<String>>, EquatableValue<List<String>>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
EquatableValue<List<String>>,
EquatableValue<List<String>>
>,
EquatableValue<List<String>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,130 @@
/*
* 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:typed_data';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'tab_session.g.dart';
@Riverpod(keepAlive: true)
class TabSession extends _$TabSession {
late GeckoSessionService _sessionService;
Future<void> loadUrl({
required Uri url,
LoadUrlFlags flags = LoadUrlFlags.NONE,
Map<String, String>? additionalHeaders,
}) {
return _sessionService.loadUrl(
url: url,
flags: flags,
additionalHeaders: additionalHeaders,
);
}
Future<void> stopLoading() {
return _sessionService.stopLoading();
}
Future<void> reload({LoadUrlFlags flags = LoadUrlFlags.NONE}) {
return _sessionService.reload(flags: flags);
}
Future<void> goBack() {
return _sessionService.goBack();
}
Future<void> goForward() {
return _sessionService.goForward();
}
Future<void> goToHistoryIndex({required int index}) {
return _sessionService.goToHistoryIndex(index: index);
}
Future<void> exitFullscreen() {
return _sessionService.exitFullscreen();
}
Future<Uint8List?> requestScreenshot({bool requireImageResult = true}) {
return _sessionService.requestScreenshot(requireImageResult);
}
Future<void> requestDesktopSite(bool enable) {
return _sessionService.requestDesktopSite(enable: enable);
}
Future<void> saveToPdf() {
return _sessionService.saveToPdf();
}
Future<void> printContent() {
return _sessionService.printContent();
}
Future<void> translate({
required String fromLanguage,
required String toLanguage,
bool? downloadModel,
}) {
return _sessionService.translate(
fromLanguage: fromLanguage,
toLanguage: toLanguage,
options: TranslationOptions(downloadModel: downloadModel ?? true),
);
}
Future<void> translateRestore() {
return _sessionService.translateRestore();
}
Future<void> pageUp() {
// Android KeyEvent.KEYCODE_PAGE_UP = 92
return _sessionService.dispatchKeyEvent(keyCode: 92);
}
Future<void> pageDown() {
// Android KeyEvent.KEYCODE_PAGE_DOWN = 93
return _sessionService.dispatchKeyEvent(keyCode: 93);
}
Future<void> scrollToTop() {
// Android KeyEvent.KEYCODE_MOVE_HOME = 122
return _sessionService.dispatchKeyEvent(keyCode: 122);
}
Future<void> scrollToBottom() {
// Android KeyEvent.KEYCODE_MOVE_END = 123
return _sessionService.dispatchKeyEvent(keyCode: 123);
}
@override
void build({required String? tabId}) {
_sessionService = (tabId != null)
? GeckoSessionService(tabId: tabId)
: GeckoSessionService.forActiveTab();
}
}
@Riverpod(keepAlive: true)
Raw<TabSession> selectedTabSessionNotifier(Ref ref) {
return ref.watch(tabSessionProvider(tabId: null).notifier);
}
@@ -0,0 +1,142 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tab_session.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(TabSession)
final tabSessionProvider = TabSessionFamily._();
final class TabSessionProvider extends $NotifierProvider<TabSession, void> {
TabSessionProvider._({
required TabSessionFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'tabSessionProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabSessionHash();
@override
String toString() {
return r'tabSessionProvider'
''
'($argument)';
}
@$internal
@override
TabSession create() => TabSession();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
@override
bool operator ==(Object other) {
return other is TabSessionProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$tabSessionHash() => r'636f0940425d689ff98fabfc61687de828a8eeb9';
final class TabSessionFamily extends $Family
with $ClassFamilyOverride<TabSession, void, void, void, String?> {
TabSessionFamily._()
: super(
retry: null,
name: r'tabSessionProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: false,
);
TabSessionProvider call({required String? tabId}) =>
TabSessionProvider._(argument: tabId, from: this);
@override
String toString() => r'tabSessionProvider';
}
abstract class _$TabSession extends $Notifier<void> {
late final _$args = ref.$arg as String?;
String? get tabId => _$args;
void build({required String? tabId});
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, () => build(tabId: _$args));
}
}
@ProviderFor(selectedTabSessionNotifier)
final selectedTabSessionProvider = SelectedTabSessionNotifierProvider._();
final class SelectedTabSessionNotifierProvider
extends
$FunctionalProvider<Raw<TabSession>, Raw<TabSession>, Raw<TabSession>>
with $Provider<Raw<TabSession>> {
SelectedTabSessionNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedTabSessionProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedTabSessionNotifierHash();
@$internal
@override
$ProviderElement<Raw<TabSession>> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Raw<TabSession> create(Ref ref) {
return selectedTabSessionNotifier(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Raw<TabSession> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Raw<TabSession>>(value),
);
}
}
String _$selectedTabSessionNotifierHash() =>
r'3945a3a3faf615e1c95819abda276f3e251d5b4c';
@@ -0,0 +1,467 @@
/*
* 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:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rxdart/rxdart.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/find_result.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/history.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/security.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/translation.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.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/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
import 'package:weblibre/features/user/data/models/tor_settings.dart';
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
part 'tab_state.g.dart';
@Riverpod(keepAlive: true)
class TabStates extends _$TabStates {
/// Disposes images from a TabState to free GPU memory.
void _disposeTabImages(TabState tab) {
tab.icon?.dispose();
tab.thumbnail?.dispose();
}
/// Updates state while disposing images from removed tabs.
void _updateState(Map<String, TabState> newState) {
// Find and dispose images from tabs that are being removed
for (final tabId in state.keys) {
if (!newState.containsKey(tabId)) {
_disposeTabImages(state[tabId]!);
}
}
state = newState;
}
Future<void> _onTabContentStateChange(TabContentState contentState) async {
final current = await patchedState(contentState.id);
final url = Uri.parse(contentState.url);
// Determine title based on priority: new non-empty title > existing title if URL authority unchanged > new title
final String resolvedTitle;
if (contentState.title.isNotEmpty) {
resolvedTitle = contentState.title;
} else if (current.url.authority == url.authority) {
resolvedTitle = current.title;
} else {
resolvedTitle = contentState.title;
}
// Infer tabMode from context ID if not already set and context is isolated
final inferredTabMode = switch (current.tabMode) {
IsolatedTabMode() => current.tabMode,
_ when isIsolatedContextId(contentState.contextId) => TabMode.isolated(
contentState.contextId!,
),
_ when contentState.isPrivate => TabMode.private,
_ => TabMode.regular,
};
final newState = current.copyWith(
parentId: contentState.parentId,
contextId: contentState.contextId,
url: url,
title: resolvedTitle,
progress: contentState.progress,
tabMode: inferredTabMode,
isFullScreen: contentState.isFullScreen,
isLoading: contentState.isLoading,
showToolbarAsExpanded: contentState.showToolbarAsExpanded,
);
_updateState({...state}..[contentState.id] = newState);
if (newState.isFinishedLoading) {
ref
.read(geckoInferenceRepositoryProvider.notifier)
.markInitialLoadComplete();
}
}
Future<TabState> patchedState(String id) async {
var current = stateOrNull?[id];
if (current == null || current.url == TabState.defaultUrl) {
current ??= TabState.$default(id);
final tabData = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabDataById(id);
if (tabData?.url != null) {
current = current.copyWith(
title: tabData!.title ?? current.title,
url: tabData.url ?? current.url,
);
}
}
return current;
}
Future<void> _onIconChange(IconChangeEvent event) async {
final IconChangeEvent(:tabId, :bytes) = event;
final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes));
final current = state[tabId] ?? TabState.$default(tabId);
// Dispose old icon only after successfully creating new one
current.icon?.dispose();
state = {...state}..[tabId] = current.copyWith.icon(image);
}
Future<void> _onThumbnailChange(ThumbnailEvent event) async {
final ThumbnailEvent(:tabId, :bytes) = event;
final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes));
final current = state[tabId] ?? TabState.$default(tabId);
// Dispose old thumbnail only after successfully creating new one
current.thumbnail?.dispose();
state = {...state}..[tabId] = current.copyWith.thumbnail(image);
}
void _onSecurityInfoStateChange(SecurityInfoEvent event) {
final SecurityInfoEvent(:tabId, :securityInfo) = event;
final current = state[tabId] ?? TabState.$default(tabId);
state = {...state}
..[tabId] = current.copyWith.securityInfoState(
SecurityState(
secure: securityInfo.secure,
host: securityInfo.host,
issuer: securityInfo.issuer,
),
);
}
void _onHistoryStateChange(HistoryEvent event) {
final HistoryEvent(:tabId, :history) = event;
final current = state[tabId] ?? TabState.$default(tabId);
state = {...state}
..[tabId] = current.copyWith.historyState(
HistoryState(
items: history.items.nonNulls
.map(
(item) =>
HistoryItem(url: Uri.parse(item.url), title: item.title),
)
.toList(),
currentIndex: history.currentIndex,
canGoBack: history.canGoBack,
canGoForward: history.canGoForward,
),
);
}
void _onReaderableStateChange(ReaderableEvent event) {
final ReaderableEvent(:tabId, :readerable) = event;
final current = state[tabId] ?? TabState.$default(tabId);
state = {...state}
..[tabId] = current.copyWith.readerableState(
ReaderableState(
readerable: readerable.readerable,
active: readerable.active,
),
);
}
void _onTabTranslationStateChange(TabTranslationEvent event) {
final TabTranslationEvent(:tabId, :state) = event;
final current = this.state[tabId] ?? TabState.$default(tabId);
this.state = {...this.state}
..[tabId] = current.copyWith.translationState(
TranslationState.fromData(state),
);
}
void _onFindResultsChange(FindResultsEvent event) {
final FindResultsEvent(:tabId, :results) = event;
final current = state[tabId] ?? TabState.$default(tabId);
if (results.isNotEmpty) {
final result = results.last;
state = {...state}
..[tabId] = current.copyWith.findResultState(
FindResultState(
lastSearchText: ref.read(findInPageRepositoryProvider(tabId)),
activeMatchOrdinal: result.activeMatchOrdinal,
numberOfMatches: result.numberOfMatches,
isDoneCounting: result.isDoneCounting,
),
);
} else if (current.findResultState.hasMatches) {
state = {
...state,
}..[tabId] = current.copyWith.findResultState(FindResultState.$default());
}
}
@override
Map<String, TabState> build() {
final eventService = ref.watch(eventServiceProvider);
final subscriptions = [
eventService.tabContentEvents.listen(
(event) async {
await _onTabContentStateChange(event);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in tab content events',
error: error,
stackTrace: stackTrace,
);
},
),
eventService.iconChangeEvents.listen(
(event) async {
await _onIconChange(event);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in icon change events',
error: error,
stackTrace: stackTrace,
);
},
),
eventService.thumbnailEvents.listen(
(event) async {
await _onThumbnailChange(event);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in thumbnail events',
error: error,
stackTrace: stackTrace,
);
},
),
eventService.securityInfoEvents.listen(
(event) {
_onSecurityInfoStateChange(event);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in security info events',
error: error,
stackTrace: stackTrace,
);
},
),
eventService.historyEvents.listen(
(event) {
_onHistoryStateChange(event);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in history events',
error: error,
stackTrace: stackTrace,
);
},
),
eventService.readerableEvents.listen(
(event) {
_onReaderableStateChange(event);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in readerable events',
error: error,
stackTrace: stackTrace,
);
},
),
eventService.findResultsEvent
.debounceTime(const Duration(milliseconds: 25))
.listen(
(event) {
_onFindResultsChange(event);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in find results events',
error: error,
stackTrace: stackTrace,
);
},
),
eventService.tabTranslationEvents.listen(
(event) {
_onTabTranslationStateChange(event);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in tab translation events',
error: error,
stackTrace: stackTrace,
);
},
),
];
ref.listen(
fireImmediately: true,
engineReadyStateProvider,
(previous, next) async {
if (next) {
await GeckoTabService().syncEvents(
onTabContentStateChange: true,
onIconChange: true,
onThumbnailChange: true,
onSecurityInfoStateChange: true,
onHistoryStateChange: true,
onFindResults: true,
onTranslationStateChange: true,
);
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to engineReadyStateProvider',
error: error,
stackTrace: stackTrace,
);
},
);
ref.onDispose(() async {
// Dispose all remaining tab images
for (final tab in state.values) {
_disposeTabImages(tab);
}
// Cancel all stream subscriptions
for (final sub in subscriptions) {
await sub.cancel();
}
});
return {};
}
}
@Riverpod()
TabState? tabState(Ref ref, String? tabId) {
if (tabId == null) {
return null;
}
return ref.watch(tabStatesProvider.select((tabs) => tabs[tabId]));
}
@Riverpod()
Future<TabState> tabStateWithFallback(Ref ref, String tabId) async {
final state = ref.watch(tabStateProvider(tabId));
if (state != null) {
return state;
}
return await ref.read(tabStatesProvider.notifier).patchedState(tabId);
}
@Riverpod()
Future<bool> isTabTunneled(Ref ref, String? tabId) async {
final tabState = ref.watch(tabStateProvider(tabId));
final torSettings = ref.watch(torSettingsWithDefaultsProvider);
if (tabState != null) {
// Isolated tabs follow the same proxy rules as regular tabs
// (container-based routing via proxy aliasing)
if (tabState.tabMode is PrivateTabMode) {
return torSettings.proxyPrivateTabsTor;
} else {
switch (torSettings.proxyRegularTabsMode) {
case TorRegularTabProxyMode.container:
final containerData = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerData(tabState.id);
if (!ref.mounted) return false;
return containerData?.metadata.useProxy ?? false;
case TorRegularTabProxyMode.all:
return true;
}
}
}
return false;
}
@Riverpod()
TabState? selectedTabState(Ref ref) {
final tabId = ref.watch(selectedTabProvider);
return ref.watch(tabStateProvider(tabId));
}
@Riverpod()
TabType? selectedTabType(Ref ref) {
final selectedState = ref.watch(selectedTabStateProvider);
return selectedState?.tabMode.toTabType();
}
@Riverpod()
AsyncValue<String?> selectedTabContainerId(Ref ref) {
final tabId = ref.watch(selectedTabProvider);
if (tabId != null) {
return ref.watch(watchContainerTabIdProvider(tabId));
}
return const AsyncData(null);
}
// @Riverpod()
// Stream<int> tabScrollY(Ref ref, String? tabId, Duration sampleTime) {
// final eventService = ref.watch(eventServiceProvider);
// return eventService.scrollEvent
// .where((event) => event.tabId == tabId)
// .sampleTime(sampleTime)
// .map((event) => event.scrollY)
// .asBroadcastStream();
// }
@@ -0,0 +1,409 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tab_state.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(TabStates)
final tabStatesProvider = TabStatesProvider._();
final class TabStatesProvider
extends $NotifierProvider<TabStates, Map<String, TabState>> {
TabStatesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabStatesProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabStatesHash();
@$internal
@override
TabStates create() => TabStates();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, TabState> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, TabState>>(value),
);
}
}
String _$tabStatesHash() => r'6fc9a91b1343f9ae0b442100bed20e42448afa37';
abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
Map<String, TabState> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<Map<String, TabState>, Map<String, TabState>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Map<String, TabState>, Map<String, TabState>>,
Map<String, TabState>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(tabState)
final tabStateProvider = TabStateFamily._();
final class TabStateProvider
extends $FunctionalProvider<TabState?, TabState?, TabState?>
with $Provider<TabState?> {
TabStateProvider._({
required TabStateFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'tabStateProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabStateHash();
@override
String toString() {
return r'tabStateProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<TabState?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
TabState? create(Ref ref) {
final argument = this.argument as String?;
return tabState(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(TabState? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<TabState?>(value),
);
}
@override
bool operator ==(Object other) {
return other is TabStateProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$tabStateHash() => r'f57b2353acc99ca73670d504b45ea5ff19df38c7';
final class TabStateFamily extends $Family
with $FunctionalFamilyOverride<TabState?, String?> {
TabStateFamily._()
: super(
retry: null,
name: r'tabStateProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
TabStateProvider call(String? tabId) =>
TabStateProvider._(argument: tabId, from: this);
@override
String toString() => r'tabStateProvider';
}
@ProviderFor(tabStateWithFallback)
final tabStateWithFallbackProvider = TabStateWithFallbackFamily._();
final class TabStateWithFallbackProvider
extends
$FunctionalProvider<AsyncValue<TabState>, TabState, FutureOr<TabState>>
with $FutureModifier<TabState>, $FutureProvider<TabState> {
TabStateWithFallbackProvider._({
required TabStateWithFallbackFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'tabStateWithFallbackProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabStateWithFallbackHash();
@override
String toString() {
return r'tabStateWithFallbackProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<TabState> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<TabState> create(Ref ref) {
final argument = this.argument as String;
return tabStateWithFallback(ref, argument);
}
@override
bool operator ==(Object other) {
return other is TabStateWithFallbackProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$tabStateWithFallbackHash() =>
r'061011ac79738dbe4e662a76fccbc8592cba21cf';
final class TabStateWithFallbackFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<TabState>, String> {
TabStateWithFallbackFamily._()
: super(
retry: null,
name: r'tabStateWithFallbackProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
TabStateWithFallbackProvider call(String tabId) =>
TabStateWithFallbackProvider._(argument: tabId, from: this);
@override
String toString() => r'tabStateWithFallbackProvider';
}
@ProviderFor(isTabTunneled)
final isTabTunneledProvider = IsTabTunneledFamily._();
final class IsTabTunneledProvider
extends $FunctionalProvider<AsyncValue<bool>, bool, FutureOr<bool>>
with $FutureModifier<bool>, $FutureProvider<bool> {
IsTabTunneledProvider._({
required IsTabTunneledFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'isTabTunneledProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$isTabTunneledHash();
@override
String toString() {
return r'isTabTunneledProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<bool> create(Ref ref) {
final argument = this.argument as String?;
return isTabTunneled(ref, argument);
}
@override
bool operator ==(Object other) {
return other is IsTabTunneledProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$isTabTunneledHash() => r'b11134b33ad0c98ffddd14afc2c790fb65735e93';
final class IsTabTunneledFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<bool>, String?> {
IsTabTunneledFamily._()
: super(
retry: null,
name: r'isTabTunneledProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
IsTabTunneledProvider call(String? tabId) =>
IsTabTunneledProvider._(argument: tabId, from: this);
@override
String toString() => r'isTabTunneledProvider';
}
@ProviderFor(selectedTabState)
final selectedTabStateProvider = SelectedTabStateProvider._();
final class SelectedTabStateProvider
extends $FunctionalProvider<TabState?, TabState?, TabState?>
with $Provider<TabState?> {
SelectedTabStateProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedTabStateProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedTabStateHash();
@$internal
@override
$ProviderElement<TabState?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
TabState? create(Ref ref) {
return selectedTabState(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(TabState? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<TabState?>(value),
);
}
}
String _$selectedTabStateHash() => r'dbd36af7af286bd9d079f30e8e11bbda23bf7728';
@ProviderFor(selectedTabType)
final selectedTabTypeProvider = SelectedTabTypeProvider._();
final class SelectedTabTypeProvider
extends $FunctionalProvider<TabType?, TabType?, TabType?>
with $Provider<TabType?> {
SelectedTabTypeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedTabTypeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedTabTypeHash();
@$internal
@override
$ProviderElement<TabType?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
TabType? create(Ref ref) {
return selectedTabType(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(TabType? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<TabType?>(value),
);
}
}
String _$selectedTabTypeHash() => r'4cf99c9175357a7835fc3c4eff29a9f328931e4f';
@ProviderFor(selectedTabContainerId)
final selectedTabContainerIdProvider = SelectedTabContainerIdProvider._();
final class SelectedTabContainerIdProvider
extends
$FunctionalProvider<
AsyncValue<String?>,
AsyncValue<String?>,
AsyncValue<String?>
>
with $Provider<AsyncValue<String?>> {
SelectedTabContainerIdProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedTabContainerIdProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedTabContainerIdHash();
@$internal
@override
$ProviderElement<AsyncValue<String?>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
AsyncValue<String?> create(Ref ref) {
return selectedTabContainerId(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<String?> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<String?>>(value),
);
}
}
String _$selectedTabContainerIdHash() =>
r'07899d29f69654d3b314d0da945ad402b1003b41';
@@ -0,0 +1,200 @@
/*
* 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:async';
import 'dart:ui';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/domain/entities/equatable_image.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/web_extension.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
import 'package:weblibre/utils/lru_cache.dart';
part 'web_extensions_state.g.dart';
@Riverpod(keepAlive: true)
class WebExtensionsState extends _$WebExtensionsState {
late final LRUCache<String, EquatableImage> _imageCache;
WebExtensionsState()
: _imageCache = LRUCache(50, onEvict: (image) => image.dispose());
void _onExtensionUpdate(ExtensionDataEvent event) {
final ExtensionDataEvent(:extensionId, :data) = event;
if (data != null) {
final cachedIcon = _imageCache.get(extensionId);
if (cachedIcon != null && cachedIcon.value == null) {
_imageCache.remove(extensionId);
}
final current =
state[extensionId] ??
WebExtensionState(
extensionId: extensionId,
icon: _imageCache.get(extensionId),
enabled: false,
);
state = {...state}
..[extensionId] = current.copyWith(
title: data.title,
enabled: data.enabled ?? current.enabled,
badgeText: data.badgeText,
badgeTextColor: data.badgeTextColor != null
? Color(data.badgeTextColor!)
: null,
badgeBackgroundColor: data.badgeBackgroundColor != null
? Color(data.badgeBackgroundColor!)
: null,
);
} else {
if (state.containsKey(extensionId)) {
state = {...state}..remove(extensionId);
// remove() triggers onEvict which handles disposal
_imageCache.remove(extensionId);
}
}
}
Future<void> _onIconChange(ExtensionIconEvent event) async {
final ExtensionIconEvent(:extensionId, :bytes) = event;
final image = await tryDecodeImage(bytes);
if (image != null) {
// set() will evict the old entry via onEvict callback, which handles disposal
_imageCache.set(extensionId, image);
if (state.containsKey(extensionId)) {
state = {...state}
..[extensionId] = state[extensionId]!.copyWith.icon(image);
}
}
}
@override
Map<String, WebExtensionState> build(WebExtensionActionType actionType) {
final addonService = ref.watch(addonServiceProvider);
final subscriptions = switch (actionType) {
WebExtensionActionType.browser => [
addonService.browserExtensionStream.listen(
(event) {
_onExtensionUpdate(event);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in browser extension stream',
error: error,
stackTrace: stackTrace,
);
},
),
addonService.browserIconStream.listen(
(event) async {
await _onIconChange(event);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in browser icon stream',
error: error,
stackTrace: stackTrace,
);
},
),
],
WebExtensionActionType.page => [
addonService.pageExtensionStream.listen(
(event) {
_onExtensionUpdate(event);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in page extension stream',
error: error,
stackTrace: stackTrace,
);
},
),
addonService.pageIconStream.listen(
(event) async {
await _onIconChange(event);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in page icon stream',
error: error,
stackTrace: stackTrace,
);
},
),
],
};
// Sync extension events after engine is ready and listeners are set up
// This ensures we get the current state even if we missed initial events
ref.listen(
fireImmediately: true,
engineReadyStateProvider,
(previous, next) async {
if (next) {
try {
await GeckoTabService().syncEvents(
onBrowserExtensionsChange:
actionType == WebExtensionActionType.browser,
onPageExtensionsChange: actionType == WebExtensionActionType.page,
onBrowserExtensionIcons:
actionType == WebExtensionActionType.browser,
onPageExtensionIcons: actionType == WebExtensionActionType.page,
);
} catch (e, s) {
logger.w(
'Failed to sync extension events for $actionType',
error: e,
stackTrace: s,
);
}
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to engineReadyStateProvider',
error: error,
stackTrace: stackTrace,
);
},
);
ref.onDispose(() async {
// Dispose all cached images
_imageCache.clear();
// Cancel all stream subscriptions
for (final sub in subscriptions) {
await sub.cancel();
}
});
return {};
}
}
@@ -0,0 +1,120 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'web_extensions_state.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(WebExtensionsState)
final webExtensionsStateProvider = WebExtensionsStateFamily._();
final class WebExtensionsStateProvider
extends
$NotifierProvider<WebExtensionsState, Map<String, WebExtensionState>> {
WebExtensionsStateProvider._({
required WebExtensionsStateFamily super.from,
required WebExtensionActionType super.argument,
}) : super(
retry: null,
name: r'webExtensionsStateProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$webExtensionsStateHash();
@override
String toString() {
return r'webExtensionsStateProvider'
''
'($argument)';
}
@$internal
@override
WebExtensionsState create() => WebExtensionsState();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, WebExtensionState> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, WebExtensionState>>(
value,
),
);
}
@override
bool operator ==(Object other) {
return other is WebExtensionsStateProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$webExtensionsStateHash() =>
r'13cbbea409b5b643eb6766689f6b23a4d1701204';
final class WebExtensionsStateFamily extends $Family
with
$ClassFamilyOverride<
WebExtensionsState,
Map<String, WebExtensionState>,
Map<String, WebExtensionState>,
Map<String, WebExtensionState>,
WebExtensionActionType
> {
WebExtensionsStateFamily._()
: super(
retry: null,
name: r'webExtensionsStateProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: false,
);
WebExtensionsStateProvider call(WebExtensionActionType actionType) =>
WebExtensionsStateProvider._(argument: actionType, from: this);
@override
String toString() => r'webExtensionsStateProvider';
}
abstract class _$WebExtensionsState
extends $Notifier<Map<String, WebExtensionState>> {
late final _$args = ref.$arg as WebExtensionActionType;
WebExtensionActionType get actionType => _$args;
Map<String, WebExtensionState> build(WebExtensionActionType actionType);
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
Map<String, WebExtensionState>,
Map<String, WebExtensionState>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
Map<String, WebExtensionState>,
Map<String, WebExtensionState>
>,
Map<String, WebExtensionState>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@@ -0,0 +1,850 @@
/*
* 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:async';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.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/entities/tab_source.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/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/tor/domain/repositories/tor_proxy.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/utils/debouncer.dart';
part 'tab.g.dart';
@Riverpod(keepAlive: true)
class TabRepository extends _$TabRepository {
final _tabsService = GeckoTabService();
final _tabFromIntent = <String>{};
final _closeLock = Lock();
final _pendingIsolationCleanup = <String>{};
bool hasLaunchedFromIntent(String? tabId) {
if (tabId == null) {
return false;
}
return _tabFromIntent.contains(tabId);
}
void clearLaunchedFromIntent(String tabId) {
_tabFromIntent.remove(tabId);
}
NewTabPosition _newTabPositionForParent(String? parentId) {
if (parentId != null) {
return NewTabPosition.first;
}
return ref.read(generalSettingsWithDefaultsProvider).newTabPosition;
}
Future<String?> _resolveParentIdForContext({
required String? parentId,
required String? targetContextId,
}) async {
if (parentId == null) {
return null;
}
final parentContainerData = await ref
.read(tabDatabaseProvider)
.tabDao
.getTabContainerData(parentId)
.getSingleOrNull();
final parentContextId = parentContainerData?.metadata.contextualIdentity;
return (parentContextId == targetContextId) ? parentId : null;
}
Future<String> addTab({
required TabMode tabMode,
Uri? url,
required bool selectTab,
bool startLoading = true,
String? parentId,
LoadUrlFlags flags = LoadUrlFlags.NONE,
Source source = Internal.newTab,
HistoryMetadataKey? historyMetadata,
Map<String, String>? additionalHeaders,
TabContainerSelection containerSelection =
const TabContainerSelection.useSelected(),
bool launchedFromIntent = false,
}) async {
final tabDao = ref.read(tabDatabaseProvider).tabDao;
final assignedContainer = switch (containerSelection) {
UseSelectedContainerTabSelection() =>
await ref.read(selectedContainerProvider.notifier).fetchData(),
UnassignedContainerTabSelection() => null,
SpecificContainerTabSelection(:final container) => container,
};
// For isolated tabs, skip parent context validation since
// isolated tabs use their own immutable context ID.
final validatedParentId = tabMode is IsolatedTabMode
? parentId
: await _resolveParentIdForContext(
parentId: parentId,
targetContextId: assignedContainer?.metadata.contextualIdentity,
);
final effectiveIsolationContextId = tabMode.isolationContextId;
final effectiveContextId = tabMode is IsolatedTabMode
? effectiveIsolationContextId
: assignedContainer?.metadata.contextualIdentity;
final newTabPosition = _newTabPositionForParent(validatedParentId);
final newTabId = await tabDao.upsertTabTransactional(
() {
return _tabsService.addTab(
url: url,
selectTab: selectTab,
startLoading: startLoading,
parentId: validatedParentId,
flags: flags,
contextId: effectiveContextId,
source: source,
private: tabMode is PrivateTabMode,
historyMetadata: historyMetadata,
additionalHeaders: additionalHeaders,
);
},
parentId: Value(validatedParentId),
newTabPosition: newTabPosition,
containerId: Value(assignedContainer?.id),
url: Value(url),
tabMode: Value(tabMode),
);
if (launchedFromIntent) {
_tabFromIntent.add(newTabId);
}
return newTabId;
}
Future<List<String>> addMultipleTabs({
required List<AddTabParams> tabs,
String? selectTabId,
TabContainerSelection containerSelection =
const TabContainerSelection.unassigned(),
}) async {
final tabDao = ref.read(tabDatabaseProvider).tabDao;
final db = ref.read(tabDatabaseProvider);
final assignedContainer = switch (containerSelection) {
UseSelectedContainerTabSelection() =>
await ref.read(selectedContainerProvider.notifier).fetchData(),
UnassignedContainerTabSelection() => null,
SpecificContainerTabSelection(:final container) => container,
};
return await db.transaction(() async {
final createdTabIds = await _tabsService.addMultipleTabs(
tabs: tabs,
selectTabId: selectTabId,
);
// Build sets for validation
final creatingTabIds = createdTabIds.toSet();
final parentIdsToValidate = tabs
.map((tab) => tab.parentId)
.whereType<String>()
.where((id) => !creatingTabIds.contains(id))
.toSet();
// Batch validate parent IDs that aren't in the current creation batch
final existingParentIds = await tabDao
.getExistingTabIds(parentIdsToValidate)
.get()
.then((ids) => ids.toSet());
// Upsert all tabs in the database
for (var i = 0; i < createdTabIds.length; i++) {
final tabId = createdTabIds[i];
final tab = tabs[i];
// Validate parent exists in either the batch being created or database
String? validatedParentId;
if (tab.parentId != null) {
if (creatingTabIds.contains(tab.parentId) ||
existingParentIds.contains(tab.parentId)) {
validatedParentId = tab.parentId;
}
}
await tabDao.insertTab(
tabId,
parentId: Value(validatedParentId),
source: TabSource.manual,
newTabPosition: _newTabPositionForParent(validatedParentId),
containerId: Value(assignedContainer?.id),
url: Value(Uri.tryParse(tab.url)),
tabMode: Value(
isIsolatedContextId(tab.contextId)
? TabMode.isolated(tab.contextId!)
: tab.private
? TabMode.private
: TabMode.regular,
),
);
}
return createdTabIds;
});
}
Future<String> duplicateTab({
required String selectTabId,
required ContainerData? containerData,
required bool selectTab,
}) async {
final tabDao = ref.read(tabDatabaseProvider).tabDao;
final sourceTabMode =
await tabDao.getTabMode(selectTabId).getSingleOrNull() ??
TabMode.regular;
// Duplicating an isolated tab creates a new isolation group
final duplicateIsolationContextId = sourceTabMode is IsolatedTabMode
? newIsolatedContextId()
: null;
final duplicateTabMode = sourceTabMode is IsolatedTabMode
? TabMode.isolated(duplicateIsolationContextId!)
: sourceTabMode;
// Isolated tabs always use their isolation context ID
final effectiveContextId = sourceTabMode is IsolatedTabMode
? duplicateIsolationContextId
: containerData?.metadata.contextualIdentity;
return await tabDao.upsertTabTransactional(
() {
return _tabsService.duplicateTab(
selectTabId: selectTabId,
newContextId: effectiveContextId,
selectNewTab: selectTab,
);
},
parentId: const Value.absent(),
newTabPosition: _newTabPositionForParent(null),
containerId: Value(containerData?.id),
tabMode: Value(duplicateTabMode),
);
}
Future<bool> selectPreviouslyOpenedTab(String tabId) async {
final previousTabId = await ref
.read(tabDatabaseProvider)
.definitionsDrift
.previousTabByTimestamp(tabId: tabId)
.getSingleOrNull();
if (ref.mounted && previousTabId != null) {
return selectTab(previousTabId);
}
return false;
}
Future<bool> resumeLatestTab() async {
final latestTab = await ref
.read(tabDatabaseProvider)
.tabDao
.getTabsFifo(limit: 1)
.getSingleOrNull();
if (!ref.mounted || latestTab == null) {
return false;
}
return selectTab(latestTab.id);
}
Future<bool> resumeLatestContainerTab(String? containerId) async {
final latestTab = await ref
.read(tabDatabaseProvider)
.tabDao
.getContainerTabsFifo(containerId, limit: 1)
.getSingleOrNull();
if (!ref.mounted || latestTab == null) {
return false;
}
return selectTab(latestTab.id);
}
Future<bool> selectPreviousTab(
String tabId, {
String? containerId,
bool skipContainerCheck = true,
}) async {
final previousTabId = await ref
.read(tabDatabaseProvider)
.definitionsDrift
.previousTabByOrderKey(
tabId: tabId,
containerId: containerId,
skipContainerCheck: skipContainerCheck,
)
.getSingleOrNull();
if (ref.mounted && previousTabId != null) {
return selectTab(previousTabId);
}
return false;
}
Future<bool> selectNextTab(
String tabId, {
String? containerId,
bool skipContainerCheck = true,
}) async {
final previousTabId = await ref
.read(tabDatabaseProvider)
.definitionsDrift
.nextTabByOrderKey(
tabId: tabId,
containerId: containerId,
skipContainerCheck: skipContainerCheck,
)
.getSingleOrNull();
if (ref.mounted && previousTabId != null) {
return selectTab(previousTabId);
}
return false;
}
Future<bool> selectTab(String tabId) async {
final containerData = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerData(tabId);
if (!ref.mounted) return false;
if (containerData != null) {
if (containerData.metadata.useProxy) {
final proxyPluginHealthy = await GeckoContainerProxyService()
.healthcheck();
if (!proxyPluginHealthy) {
logger.w(
'Tried to open proxied tab $tabId but proxy plugin not responding',
);
return false;
}
}
}
await _tabsService.selectTab(tabId: tabId);
return true;
}
Future<void> _selectNextTab(String tabId) async {
final tabState = ref.read(tabStatesProvider)[tabId];
final currentContainerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerId(tabId);
if (!ref.mounted) return;
final sameContainerTabs = await ref
.read(containerRepositoryProvider.notifier)
.getContainerTabIds(currentContainerId)
.then((tabs) => tabs.where((tab) => tab != tabId).toList());
if (!ref.mounted) return;
// Priority 1: Check for parent tab first
if (tabState?.parentId != null) {
return _tabsService.selectTab(tabId: tabState!.parentId!);
}
// Priority 2: Check for previous tab by timestamp
final previousTabId = await ref
.read(tabDatabaseProvider)
.definitionsDrift
.previousTabByTimestamp(tabId: tabId)
.getSingleOrNull();
if (previousTabId != null) {
if (sameContainerTabs.any((tab) => tab == previousTabId)) {
return _tabsService.selectTab(tabId: previousTabId);
}
}
if (!ref.mounted) return;
final previousOrderedTabId = await ref
.read(tabDatabaseProvider)
.definitionsDrift
.previousTabByOrderKey(
tabId: tabId,
containerId: currentContainerId,
skipContainerCheck: false,
)
.getSingleOrNull();
if (previousOrderedTabId != null) {
return _tabsService.selectTab(tabId: previousOrderedTabId);
}
if (!ref.mounted) return;
final nextOrderedTabId = await ref
.read(tabDatabaseProvider)
.definitionsDrift
.nextTabByOrderKey(
tabId: tabId,
containerId: currentContainerId,
skipContainerCheck: false,
)
.getSingleOrNull();
if (nextOrderedTabId != null) {
return _tabsService.selectTab(tabId: nextOrderedTabId);
}
if (!ref.mounted) return;
final unassignedTabs = await ref
.read(containerRepositoryProvider.notifier)
.getContainerTabIds(null)
.then((tabs) => tabs.where((tab) => tab != tabId).toList());
if (unassignedTabs.isNotEmpty) {
return _tabsService.selectTab(tabId: unassignedTabs.first);
}
if (!ref.mounted) return;
final availableContainers = await ref
.read(containerRepositoryProvider.notifier)
.getAllContainersWithCount();
final nextAvailableContainer = availableContainers.firstOrNull;
if (!ref.mounted) return;
final nextContainerTabs = await nextAvailableContainer.mapNotNull(
(container) => ref
.read(containerRepositoryProvider.notifier)
.getContainerTabIds(container.id)
.then((tabs) => tabs.where((tab) => tab != tabId).toList()),
);
if (nextContainerTabs.isNotEmpty) {
return _tabsService.selectTab(tabId: nextContainerTabs!.first);
}
}
Future<void> closeTab(String tabId) {
return _closeLock.synchronized(() async {
// Collect isolation context before close
final isolationContextId = ref
.read(tabStatesProvider)[tabId]
?.isolationContextId;
if (ref.read(selectedTabProvider) == tabId) {
await _selectNextTab(tabId);
}
await _tabsService.removeTab(tabId: tabId);
// Queue isolation cleanup — actual cleanup runs after syncTabs
// deletes the DB row, so the count check is accurate.
if (isolationContextId != null) {
_pendingIsolationCleanup.add(isolationContextId);
}
});
}
Future<void> closeTabs(List<String> tabIds) {
return _closeLock.synchronized(() async {
// Collect isolation contexts from tabs being closed
for (final tabId in tabIds) {
final contextId = ref
.read(tabStatesProvider)[tabId]
?.isolationContextId;
if (contextId != null) {
_pendingIsolationCleanup.add(contextId);
}
}
final selectedTab = ref.read(selectedTabProvider);
if (selectedTab.mapNotNull(tabIds.contains) ?? false) {
await _selectNextTab(selectedTab!);
}
await _tabsService.removeTabs(ids: tabIds);
});
}
/// Clears Gecko browsing data and removes proxy alias for an isolation
/// context if no more tabs share it.
Future<void> _cleanupIsolationContextIfEmpty(String contextId) async {
final tabDao = ref.read(tabDatabaseProvider).tabDao;
// Re-verify count after close (handles concurrent close races)
final remaining = await tabDao.tabsInIsolationGroup(contextId).getSingle();
if (remaining > 0) return;
// Guard against debounced DB persistence: a sibling tab can already be
// active in-memory for this context before isolation_context_id is written.
final activeTabs = ref.read(tabListProvider).value;
final activeStates = ref.read(tabStatesProvider);
final hasActiveSibling = activeTabs.any((tabId) {
final state = activeStates[tabId];
if (state == null) return false;
return state.isolationContextId == contextId ||
state.contextId == contextId;
});
if (hasActiveSibling) {
logger.i(
'Skipping isolation cleanup for active context still in memory: $contextId',
);
return;
}
logger.i('Cleaning up isolation context: $contextId');
// Clear Gecko browsing data for this context
try {
await ref
.read(browserDataServiceProvider.notifier)
.clearDataForContext(contextId);
} catch (e, st) {
logger.e(
'Failed to clear data for isolation context $contextId',
error: e,
stackTrace: st,
);
}
// Best-effort: remove proxy alias (no-op if never set)
try {
await ref
.read(torProxyRepositoryProvider.notifier)
.removeContainerProxy(contextId);
} catch (e, st) {
logger.e(
'Failed to remove proxy for isolation context $contextId',
error: e,
stackTrace: st,
);
}
}
Future<void> undoClose() {
return _tabsService.undo();
}
/// Cleans up isolation contexts from previous crashed sessions.
/// Called once after tab list stabilizes on startup.
// Future<void> _cleanupOrphanedIsolationContexts() async {
// final tabDao = ref.read(tabDatabaseProvider).tabDao;
// try {
// await _closeLock.synchronized(() async {
// if (!ref.mounted) return;
// // Reconcile DB rows against the current engine tab snapshot, including
// // valid empty-tab sessions (retainTabIds can be empty here).
// final syncTabsResult = await tabDao.syncTabs(
// retainTabIds: ref.read(tabListProvider).value,
// );
// _pendingIsolationCleanup.addAll(
// syncTabsResult.deletedIsolationContextIds,
// );
// if (_pendingIsolationCleanup.isNotEmpty) {
// final pending = Set<String>.of(_pendingIsolationCleanup);
// _pendingIsolationCleanup.clear();
// for (final contextId in pending) {
// if (!ref.mounted) return;
// logger.i('Cleaning orphaned isolation context: $contextId');
// await _cleanupIsolationContextIfEmpty(contextId);
// }
// }
// });
// } catch (e, st) {
// logger.e(
// 'Error during orphan isolation context cleanup',
// error: e,
// stackTrace: st,
// );
// }
// }
@override
void build() {
final eventSerivce = ref.watch(eventServiceProvider);
final tabContentService = ref.watch(tabContentServiceProvider);
final db = ref.watch(tabDatabaseProvider);
final tabAddedSub = eventSerivce.tabAddedStream.listen(
(tabId) async {
final containerId = ref.read(selectedContainerProvider);
await db.tabDao.insertTab(
tabId,
parentId: const Value.absent(),
source: TabSource.addedEvent,
newTabPosition: _newTabPositionForParent(null),
containerId: Value(containerId),
);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in tab added stream',
error: error,
stackTrace: stackTrace,
);
},
);
final containerSiteAssignementSub = eventSerivce.siteAssignementEvent.listen(
(event) async {
if (event.tabId != null) {
final tabState = ref.read(tabStatesProvider)[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.defaultUrl &&
tabState.historyState.items.isEmpty;
if (event.blocked || tabIsEmpty) {
await addTab(
url: uri,
tabMode: tabState.tabMode,
containerSelection: TabContainerSelection.specific(
containerData,
),
parentId: tabState.id,
selectTab: true,
);
if (tabState.historyState.items.isEmpty) {
await closeTab(tabState.id);
}
} else {
final tabContainerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerId(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}');
}
}
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in container site assignment stream',
error: error,
stackTrace: stackTrace,
);
},
);
final tabContentSub = tabContentService.tabContentStream.listen(
(content) async {
await db.tabDao.updateTabContent(
content.tabId,
isProbablyReaderable: content.isProbablyReaderable,
extractedContentMarkdown: content.extractedContentMarkdown,
extractedContentPlain: content.extractedContentPlain,
fullContentMarkdown: content.fullContentMarkdown,
fullContentPlain: content.fullContentPlain,
);
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in tab content stream',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listen(
fireImmediately: true,
selectedTabProvider,
(previous, tabId) async {
if (tabId != null) {
await db.tabDao.touchTab(tabId, timestamp: DateTime.now());
}
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error listening to selectedTabProvider',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listen(
tabListProvider,
(previous, next) async {
//Only sync tabs if there has been a previous value or is not empty
final shouldSyncTabs =
next.value.isNotEmpty || (previous?.value.isNotEmpty ?? false);
if (shouldSyncTabs) {
final syncTabsResult = await db.tabDao.syncTabs(
retainTabIds: next.value,
);
// Capture isolation contexts from rows deleted by syncTabs
// (orphaned tabs from crashes, or tabs the engine dropped).
_pendingIsolationCleanup.addAll(
syncTabsResult.deletedIsolationContextIds,
);
}
// Process pending isolation context cleanups after syncTabs
// has deleted the rows, so the count check is accurate.
if (_pendingIsolationCleanup.isNotEmpty) {
final pending = Set<String>.of(_pendingIsolationCleanup);
_pendingIsolationCleanup.clear();
for (final contextId in pending) {
if (!ref.mounted) break;
await _cleanupIsolationContextIfEmpty(contextId);
}
}
// One-shot orphan cleanup after tab list stabilizes (5s debounce).
// Also runs for DB-only contexts whose rows were already deleted
// by syncTabs above (those are handled via _pendingIsolationCleanup).
// if (!orphanCleanupDone) {
// orphanCleanupTimer?.cancel();
// orphanCleanupTimer = Timer(const Duration(seconds: 5), () async {
// if (orphanCleanupDone || !ref.mounted) return;
// orphanCleanupDone = true;
// await _cleanupOrphanedIsolationContexts();
// });
// }
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error listening to tabListProvider',
error: error,
stackTrace: stackTrace,
);
},
);
final tabStateDebouncer = Debouncer(const Duration(seconds: 1));
Map<String, TabState>? debounceStartValue;
ref.listen(
tabStatesProvider,
(previous, next) {
//Since state changes occure pretty often and our map always contains
//the latest state, we cache the value before starting debouncing and
//later diff to that, to avoid frequent database writes
if (!tabStateDebouncer.isDebouncing) {
debounceStartValue = previous;
}
tabStateDebouncer.eventOccured(() async {
await db.tabDao.updateTabs(debounceStartValue, next);
});
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error listening to tabStatesProvider',
error: error,
stackTrace: stackTrace,
);
},
);
ref.onDispose(() async {
tabStateDebouncer.dispose();
await tabAddedSub.cancel();
await tabContentSub.cancel();
await containerSiteAssignementSub.cancel();
});
}
}
@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tab.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(TabRepository)
final tabRepositoryProvider = TabRepositoryProvider._();
final class TabRepositoryProvider
extends $NotifierProvider<TabRepository, void> {
TabRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabRepositoryHash();
@$internal
@override
TabRepository create() => TabRepository();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$tabRepositoryHash() => r'ce4ac2f2efbb859ba66e98c6e6d21f8205d9b4ed';
abstract class _$TabRepository extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,164 @@
/*
* 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 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:json_annotation/json_annotation.dart';
part 'bookmark_item.g.dart';
sealed class BookmarkItem {
abstract final String guid;
abstract final String? parentGuid;
abstract final String title;
abstract final int dateAdded;
abstract final int? position;
const BookmarkItem();
static BookmarkItem? parseRecursive(BookmarkNode node) {
return switch (node.type) {
BookmarkNodeType.item => BookmarkEntry(
guid: node.guid,
parentGuid: node.parentGuid,
url: Uri.parse(node.url!),
title: node.title ?? node.url!,
previewImageUrl: Uri.parse(node.url!),
position: node.position,
dateAdded: node.dateAdded,
),
BookmarkNodeType.folder => BookmarkFolder(
guid: node.guid,
parentGuid: node.parentGuid,
title: node.title ?? "Unnamed Folder",
position: node.position,
dateAdded: node.dateAdded,
children: node.children?.map(parseRecursive).nonNulls.toList(),
),
BookmarkNodeType.separator => null,
};
}
factory BookmarkItem.fromJson(Map<String, dynamic> json) {
return (json.containsKey('url'))
? BookmarkEntry.fromJson(json)
: BookmarkFolder.fromJson(json);
}
Map<String, dynamic> toJson();
BookmarkItem clone();
}
@CopyWith()
@JsonSerializable()
class BookmarkEntry extends BookmarkItem with FastEquatable {
@override
final String guid;
@override
final String? parentGuid;
final Uri url;
@override
final String title;
final Uri previewImageUrl;
@override
final int? position;
@override
final int dateAdded;
BookmarkEntry({
required this.guid,
required this.parentGuid,
required this.url,
required this.title,
required this.previewImageUrl,
required this.position,
required this.dateAdded,
});
factory BookmarkEntry.fromJson(Map<String, dynamic> json) =>
_$BookmarkEntryFromJson(json);
@override
Map<String, dynamic> toJson() => _$BookmarkEntryToJson(this);
@override
BookmarkItem clone() {
return copyWith();
}
@override
List<Object?> get hashParameters => [
guid,
parentGuid,
url,
title,
previewImageUrl,
position,
dateAdded,
];
}
@CopyWith()
@JsonSerializable()
class BookmarkFolder extends BookmarkItem with FastEquatable {
@override
final String guid;
@override
final String? parentGuid;
@override
final String title;
@override
final int? position;
@override
final int dateAdded;
final List<BookmarkItem>? children;
BookmarkFolder({
required this.guid,
required this.parentGuid,
required String title,
required this.position,
required this.dateAdded,
required this.children,
}) : title = bookmarkRootDisplayNames[guid] ?? title;
factory BookmarkFolder.fromJson(Map<String, dynamic> json) =>
_$BookmarkFolderFromJson(json);
@override
Map<String, dynamic> toJson() => _$BookmarkFolderToJson(this);
@override
BookmarkItem clone() {
return copyWith();
}
@override
List<Object?> get hashParameters => [
guid,
parentGuid,
title,
position,
dateAdded,
children,
];
}
@@ -0,0 +1,284 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bookmark_item.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$BookmarkEntryCWProxy {
BookmarkEntry guid(String guid);
BookmarkEntry parentGuid(String? parentGuid);
BookmarkEntry url(Uri url);
BookmarkEntry title(String title);
BookmarkEntry previewImageUrl(Uri previewImageUrl);
BookmarkEntry position(int? position);
BookmarkEntry dateAdded(int dateAdded);
/// 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 `BookmarkEntry(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// BookmarkEntry(...).copyWith(id: 12, name: "My name")
/// ```
BookmarkEntry call({
String guid,
String? parentGuid,
Uri url,
String title,
Uri previewImageUrl,
int? position,
int dateAdded,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfBookmarkEntry.copyWith(...)` or call `instanceOfBookmarkEntry.copyWith.fieldName(value)` for a single field.
class _$BookmarkEntryCWProxyImpl implements _$BookmarkEntryCWProxy {
const _$BookmarkEntryCWProxyImpl(this._value);
final BookmarkEntry _value;
@override
BookmarkEntry guid(String guid) => call(guid: guid);
@override
BookmarkEntry parentGuid(String? parentGuid) => call(parentGuid: parentGuid);
@override
BookmarkEntry url(Uri url) => call(url: url);
@override
BookmarkEntry title(String title) => call(title: title);
@override
BookmarkEntry previewImageUrl(Uri previewImageUrl) =>
call(previewImageUrl: previewImageUrl);
@override
BookmarkEntry position(int? position) => call(position: position);
@override
BookmarkEntry dateAdded(int dateAdded) => call(dateAdded: dateAdded);
@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 `BookmarkEntry(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// BookmarkEntry(...).copyWith(id: 12, name: "My name")
/// ```
BookmarkEntry call({
Object? guid = const $CopyWithPlaceholder(),
Object? parentGuid = const $CopyWithPlaceholder(),
Object? url = const $CopyWithPlaceholder(),
Object? title = const $CopyWithPlaceholder(),
Object? previewImageUrl = const $CopyWithPlaceholder(),
Object? position = const $CopyWithPlaceholder(),
Object? dateAdded = const $CopyWithPlaceholder(),
}) {
return BookmarkEntry(
guid: guid == const $CopyWithPlaceholder() || guid == null
? _value.guid
// ignore: cast_nullable_to_non_nullable
: guid as String,
parentGuid: parentGuid == const $CopyWithPlaceholder()
? _value.parentGuid
// ignore: cast_nullable_to_non_nullable
: parentGuid as String?,
url: url == const $CopyWithPlaceholder() || url == null
? _value.url
// ignore: cast_nullable_to_non_nullable
: url as Uri,
title: title == const $CopyWithPlaceholder() || title == null
? _value.title
// ignore: cast_nullable_to_non_nullable
: title as String,
previewImageUrl:
previewImageUrl == const $CopyWithPlaceholder() ||
previewImageUrl == null
? _value.previewImageUrl
// ignore: cast_nullable_to_non_nullable
: previewImageUrl as Uri,
position: position == const $CopyWithPlaceholder()
? _value.position
// ignore: cast_nullable_to_non_nullable
: position as int?,
dateAdded: dateAdded == const $CopyWithPlaceholder() || dateAdded == null
? _value.dateAdded
// ignore: cast_nullable_to_non_nullable
: dateAdded as int,
);
}
}
extension $BookmarkEntryCopyWith on BookmarkEntry {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfBookmarkEntry.copyWith(...)` or `instanceOfBookmarkEntry.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$BookmarkEntryCWProxy get copyWith => _$BookmarkEntryCWProxyImpl(this);
}
abstract class _$BookmarkFolderCWProxy {
BookmarkFolder guid(String guid);
BookmarkFolder parentGuid(String? parentGuid);
BookmarkFolder title(String title);
BookmarkFolder position(int? position);
BookmarkFolder dateAdded(int dateAdded);
BookmarkFolder children(List<BookmarkItem>? children);
/// 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 `BookmarkFolder(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// BookmarkFolder(...).copyWith(id: 12, name: "My name")
/// ```
BookmarkFolder call({
String guid,
String? parentGuid,
String title,
int? position,
int dateAdded,
List<BookmarkItem>? children,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfBookmarkFolder.copyWith(...)` or call `instanceOfBookmarkFolder.copyWith.fieldName(value)` for a single field.
class _$BookmarkFolderCWProxyImpl implements _$BookmarkFolderCWProxy {
const _$BookmarkFolderCWProxyImpl(this._value);
final BookmarkFolder _value;
@override
BookmarkFolder guid(String guid) => call(guid: guid);
@override
BookmarkFolder parentGuid(String? parentGuid) => call(parentGuid: parentGuid);
@override
BookmarkFolder title(String title) => call(title: title);
@override
BookmarkFolder position(int? position) => call(position: position);
@override
BookmarkFolder dateAdded(int dateAdded) => call(dateAdded: dateAdded);
@override
BookmarkFolder children(List<BookmarkItem>? children) =>
call(children: children);
@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 `BookmarkFolder(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// BookmarkFolder(...).copyWith(id: 12, name: "My name")
/// ```
BookmarkFolder call({
Object? guid = const $CopyWithPlaceholder(),
Object? parentGuid = const $CopyWithPlaceholder(),
Object? title = const $CopyWithPlaceholder(),
Object? position = const $CopyWithPlaceholder(),
Object? dateAdded = const $CopyWithPlaceholder(),
Object? children = const $CopyWithPlaceholder(),
}) {
return BookmarkFolder(
guid: guid == const $CopyWithPlaceholder() || guid == null
? _value.guid
// ignore: cast_nullable_to_non_nullable
: guid as String,
parentGuid: parentGuid == const $CopyWithPlaceholder()
? _value.parentGuid
// ignore: cast_nullable_to_non_nullable
: parentGuid as String?,
title: title == const $CopyWithPlaceholder() || title == null
? _value.title
// ignore: cast_nullable_to_non_nullable
: title as String,
position: position == const $CopyWithPlaceholder()
? _value.position
// ignore: cast_nullable_to_non_nullable
: position as int?,
dateAdded: dateAdded == const $CopyWithPlaceholder() || dateAdded == null
? _value.dateAdded
// ignore: cast_nullable_to_non_nullable
: dateAdded as int,
children: children == const $CopyWithPlaceholder()
? _value.children
// ignore: cast_nullable_to_non_nullable
: children as List<BookmarkItem>?,
);
}
}
extension $BookmarkFolderCopyWith on BookmarkFolder {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfBookmarkFolder.copyWith(...)` or `instanceOfBookmarkFolder.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$BookmarkFolderCWProxy get copyWith => _$BookmarkFolderCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BookmarkEntry _$BookmarkEntryFromJson(Map<String, dynamic> json) =>
BookmarkEntry(
guid: json['guid'] as String,
parentGuid: json['parentGuid'] as String?,
url: Uri.parse(json['url'] as String),
title: json['title'] as String,
previewImageUrl: Uri.parse(json['previewImageUrl'] as String),
position: (json['position'] as num?)?.toInt(),
dateAdded: (json['dateAdded'] as num).toInt(),
);
Map<String, dynamic> _$BookmarkEntryToJson(BookmarkEntry instance) =>
<String, dynamic>{
'guid': instance.guid,
'parentGuid': instance.parentGuid,
'url': instance.url.toString(),
'title': instance.title,
'previewImageUrl': instance.previewImageUrl.toString(),
'position': instance.position,
'dateAdded': instance.dateAdded,
};
BookmarkFolder _$BookmarkFolderFromJson(Map<String, dynamic> json) =>
BookmarkFolder(
guid: json['guid'] as String,
parentGuid: json['parentGuid'] as String?,
title: json['title'] as String,
position: (json['position'] as num?)?.toInt(),
dateAdded: (json['dateAdded'] as num).toInt(),
children: (json['children'] as List<dynamic>?)
?.map((e) => BookmarkItem.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$BookmarkFolderToJson(BookmarkFolder instance) =>
<String, dynamic>{
'guid': instance.guid,
'parentGuid': instance.parentGuid,
'title': instance.title,
'position': instance.position,
'dateAdded': instance.dateAdded,
'children': instance.children?.map((e) => e.toJson()).toList(),
};
@@ -0,0 +1,47 @@
/*
* 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 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
part 'bookmark_list_ui_state.g.dart';
@CopyWith()
class BookmarkListUiState with FastEquatable {
final bool selectionMode;
final Set<String> selectedGuids;
final BookmarkSortType sortType;
final bool foldersOnly;
BookmarkListUiState({
this.selectionMode = false,
this.selectedGuids = const {},
this.sortType = BookmarkSortType.manual,
this.foldersOnly = false,
});
@override
List<Object?> get hashParameters => [
selectionMode,
selectedGuids,
sortType,
foldersOnly,
];
}
@@ -0,0 +1,100 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bookmark_list_ui_state.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$BookmarkListUiStateCWProxy {
BookmarkListUiState selectionMode(bool selectionMode);
BookmarkListUiState selectedGuids(Set<String> selectedGuids);
BookmarkListUiState sortType(BookmarkSortType sortType);
BookmarkListUiState foldersOnly(bool foldersOnly);
/// 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 `BookmarkListUiState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// BookmarkListUiState(...).copyWith(id: 12, name: "My name")
/// ```
BookmarkListUiState call({
bool selectionMode,
Set<String> selectedGuids,
BookmarkSortType sortType,
bool foldersOnly,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfBookmarkListUiState.copyWith(...)` or call `instanceOfBookmarkListUiState.copyWith.fieldName(value)` for a single field.
class _$BookmarkListUiStateCWProxyImpl implements _$BookmarkListUiStateCWProxy {
const _$BookmarkListUiStateCWProxyImpl(this._value);
final BookmarkListUiState _value;
@override
BookmarkListUiState selectionMode(bool selectionMode) =>
call(selectionMode: selectionMode);
@override
BookmarkListUiState selectedGuids(Set<String> selectedGuids) =>
call(selectedGuids: selectedGuids);
@override
BookmarkListUiState sortType(BookmarkSortType sortType) =>
call(sortType: sortType);
@override
BookmarkListUiState foldersOnly(bool foldersOnly) =>
call(foldersOnly: foldersOnly);
@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 `BookmarkListUiState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// BookmarkListUiState(...).copyWith(id: 12, name: "My name")
/// ```
BookmarkListUiState call({
Object? selectionMode = const $CopyWithPlaceholder(),
Object? selectedGuids = const $CopyWithPlaceholder(),
Object? sortType = const $CopyWithPlaceholder(),
Object? foldersOnly = const $CopyWithPlaceholder(),
}) {
return BookmarkListUiState(
selectionMode:
selectionMode == const $CopyWithPlaceholder() || selectionMode == null
? _value.selectionMode
// ignore: cast_nullable_to_non_nullable
: selectionMode as bool,
selectedGuids:
selectedGuids == const $CopyWithPlaceholder() || selectedGuids == null
? _value.selectedGuids
// ignore: cast_nullable_to_non_nullable
: selectedGuids as Set<String>,
sortType: sortType == const $CopyWithPlaceholder() || sortType == null
? _value.sortType
// ignore: cast_nullable_to_non_nullable
: sortType as BookmarkSortType,
foldersOnly:
foldersOnly == const $CopyWithPlaceholder() || foldersOnly == null
? _value.foldersOnly
// ignore: cast_nullable_to_non_nullable
: foldersOnly as bool,
);
}
}
extension $BookmarkListUiStateCopyWith on BookmarkListUiState {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfBookmarkListUiState.copyWith(...)` or `instanceOfBookmarkListUiState.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$BookmarkListUiStateCWProxy get copyWith =>
_$BookmarkListUiStateCWProxyImpl(this);
}
@@ -0,0 +1,63 @@
/*
* 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 'package:weblibre/core/sort_field.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
enum BookmarkSortType {
manual('Default', null),
titleAsc('Title A-Z', SortField.titleAsc),
titleDesc('Title Z-A', SortField.titleDesc),
urlAsc('URL A-Z', SortField.urlAsc),
urlDesc('URL Z-A', SortField.urlDesc),
dateAddedDesc('Newest First', SortField.dateDesc),
dateAddedAsc('Oldest First', SortField.dateAsc);
final String label;
final SortField? sortField;
const BookmarkSortType(this.label, this.sortField);
}
int compareBookmarkItems(
BookmarkItem a,
BookmarkItem b,
BookmarkSortType sort,
) {
final sortField = sort.sortField;
if (sortField == null) {
return 0;
}
return switch (sortField) {
SortField.titleAsc => a.title.toLowerCase().compareTo(
b.title.toLowerCase(),
),
SortField.titleDesc => b.title.toLowerCase().compareTo(
a.title.toLowerCase(),
),
SortField.urlAsc => _urlKey(a).compareTo(_urlKey(b)),
SortField.urlDesc => _urlKey(b).compareTo(_urlKey(a)),
SortField.dateAsc => a.dateAdded.compareTo(b.dateAdded),
SortField.dateDesc => b.dateAdded.compareTo(a.dateAdded),
};
}
String _urlKey(BookmarkItem item) =>
item is BookmarkEntry ? item.url.toString() : item.title.toLowerCase();
@@ -0,0 +1,74 @@
/*
* 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 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
part 'bookmark_list_ui_state.g.dart';
@Riverpod()
class BookmarkListUiStateNotifier extends _$BookmarkListUiStateNotifier {
@override
BookmarkListUiState build() => BookmarkListUiState();
void enterSelectionMode({String? initialGuid}) {
state = state.copyWith(
selectionMode: true,
selectedGuids: initialGuid != null ? {initialGuid} : {},
);
}
void exitSelectionMode() {
state = state.copyWith(selectionMode: false, selectedGuids: {});
}
void toggleSelection(String guid) {
final updated = Set<String>.from(state.selectedGuids);
if (updated.contains(guid)) {
updated.remove(guid);
} else {
updated.add(guid);
}
if (updated.isEmpty) {
exitSelectionMode();
} else {
state = state.copyWith(selectedGuids: updated);
}
}
void selectAll(Iterable<String> guids) {
state = state.copyWith(
selectionMode: true,
selectedGuids: Set<String>.from(guids),
);
}
void clearSelection() {
exitSelectionMode();
}
void setSortType(BookmarkSortType sortType) {
state = state.copyWith(sortType: sortType);
}
void toggleFoldersOnly() {
state = state.copyWith(foldersOnly: !state.foldersOnly);
}
}
@@ -0,0 +1,65 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bookmark_list_ui_state.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BookmarkListUiStateNotifier)
final bookmarkListUiStateProvider = BookmarkListUiStateNotifierProvider._();
final class BookmarkListUiStateNotifierProvider
extends
$NotifierProvider<BookmarkListUiStateNotifier, BookmarkListUiState> {
BookmarkListUiStateNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bookmarkListUiStateProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bookmarkListUiStateNotifierHash();
@$internal
@override
BookmarkListUiStateNotifier create() => BookmarkListUiStateNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(BookmarkListUiState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<BookmarkListUiState>(value),
);
}
}
String _$bookmarkListUiStateNotifierHash() =>
r'7c764fc2f1deb178063ca95764775ca237471f9f';
abstract class _$BookmarkListUiStateNotifier
extends $Notifier<BookmarkListUiState> {
BookmarkListUiState build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<BookmarkListUiState, BookmarkListUiState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<BookmarkListUiState, BookmarkListUiState>,
BookmarkListUiState,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,257 @@
/*
* 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:async';
import 'package:flutter/services.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
part 'bookmarks.g.dart';
/// Check if a root folder is effectively empty (has no non-root children)
bool _isEmptyRootFolder(BookmarkFolder folder) {
if (folder.children == null) return true;
// A root folder is empty if it has no children, or only contains other root folders
return folder.children!.every(
(child) => bookmarkRootIds.contains(child.guid),
);
}
T? _selectChildRecursive<T extends BookmarkItem>(
List<BookmarkItem> children,
String guid,
) {
for (final child in children) {
if (child.guid == guid && child is T) {
return child;
}
if (child case final BookmarkFolder folder) {
if (folder.children != null) {
final result = _selectChildRecursive<T>(folder.children!, guid);
if (result != null) {
return result;
}
}
}
}
return null;
}
T _cloneAndFilterChildrenType<T extends BookmarkItem>(T node) {
if (node is BookmarkFolder) {
if (node.children != null) {
return node.copyWith.children(
node.children
?.whereType<T>()
.map((e) => _cloneAndFilterChildrenType<T>(e))
.toList(),
)
as T;
}
}
return node.clone() as T;
}
BookmarkItem? _cloneAndFilterOnGuids(BookmarkItem node, Set<String> guids) {
if (node is BookmarkFolder) {
if (node.children != null) {
final filtered = node.children
?.where((e) => e is BookmarkFolder || guids.contains(e.guid))
.map((e) => _cloneAndFilterOnGuids(e, guids))
.nonNulls
.toList();
if (filtered.isNotEmpty) {
return node.copyWith.children(filtered);
} else {
return null;
}
}
}
if (guids.contains(node.guid)) {
return node.clone();
}
return null;
}
@Riverpod()
class BookmarksSearch extends _$BookmarksSearch {
final _service = GeckoBookmarksService();
late StreamController<Set<String>> _streamController;
Future<void> search(String query, {int limit = 10}) async {
if (query.isNotEmpty) {
try {
await _service.searchBookmarks(query, limit: limit).then((value) {
if (!_streamController.isClosed) {
_streamController.add(value.map((e) => e.guid).toSet());
}
});
} on PlatformException catch (e) {
if (e.code == 'OperationInterrupted') return;
rethrow;
}
}
}
@override
Stream<Set<String>> build() {
_streamController = StreamController();
ref.onDispose(() async {
await _streamController.close();
});
return _streamController.stream;
}
}
@Riverpod()
class BookmarkSearchResults extends _$BookmarkSearchResults {
final _service = GeckoBookmarksService();
Future<void> search(String query, {int limit = 10}) async {
if (query.isEmpty) {
state = [];
return;
}
try {
final results = await _service.searchBookmarks(query, limit: limit);
if (!ref.mounted) return;
state = results
.map(BookmarkItem.parseRecursive)
.whereType<BookmarkEntry>()
.toList();
} on PlatformException catch (e) {
if (e.code == 'OperationInterrupted') return;
rethrow;
}
}
@override
List<BookmarkEntry> build() {
return [];
}
}
@Riverpod()
AsyncValue<T?> bookmarks<T extends BookmarkItem>(
Ref ref,
String entryGuid, {
bool hideEmptyRoots = false,
}) {
final bookmarksAsync = ref.watch(bookmarksRepositoryProvider);
return bookmarksAsync.whenData((bookmarkNode) {
T? selectedNode;
if (bookmarkNode != null && bookmarkNode is T) {
if (bookmarkNode.guid == entryGuid) {
selectedNode = bookmarkNode;
} else if (bookmarkNode case final BookmarkFolder folder) {
if (folder.children != null) {
selectedNode = _selectChildRecursive<T>(folder.children!, entryGuid);
}
}
}
if (selectedNode != null) {
var result = _cloneAndFilterChildrenType<T>(selectedNode);
// Filter empty root folders when viewing root level (excluding WebLibre root)
if (hideEmptyRoots &&
entryGuid == BookmarkRoot.root.id &&
result is BookmarkFolder) {
final filteredChildren = result.children
?.where(
(child) =>
child is! BookmarkFolder ||
child.guid == BookmarkRoot.mobile.id ||
!_isEmptyRootFolder(child),
)
.toList();
result = result.copyWith.children(filteredChildren) as T;
}
return result;
}
return null;
});
}
@Riverpod()
class SeamlessBookmarks extends _$SeamlessBookmarks {
bool _hasSearch = false;
void search(String input) {
if (input.isNotEmpty) {
if (!_hasSearch) {
_hasSearch = true;
ref.invalidateSelf();
}
//Don't block
unawaited(ref.read(bookmarksSearchProvider.notifier).search(input));
} else if (_hasSearch) {
_hasSearch = false;
ref.invalidateSelf();
}
}
@override
AsyncValue<BookmarkItem?> build(
String entryGuid, {
bool hideEmptyRoots = false,
}) {
final bookmarks = ref.watch(
bookmarksProvider<BookmarkItem>(
entryGuid,
hideEmptyRoots: hideEmptyRoots,
),
);
if (_hasSearch) {
final filterGuids = ref.watch(bookmarksSearchProvider);
return bookmarks.map(
data: (node) =>
node.value.mapNotNull(
(node) => filterGuids.whenData(
(results) => _cloneAndFilterOnGuids(node, results),
),
) ??
const AsyncValue.data(null),
error: (e) => e,
loading: (s) => s,
);
} else {
return bookmarks;
}
}
}
@@ -0,0 +1,330 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bookmarks.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BookmarksSearch)
final bookmarksSearchProvider = BookmarksSearchProvider._();
final class BookmarksSearchProvider
extends $StreamNotifierProvider<BookmarksSearch, Set<String>> {
BookmarksSearchProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bookmarksSearchProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bookmarksSearchHash();
@$internal
@override
BookmarksSearch create() => BookmarksSearch();
}
String _$bookmarksSearchHash() => r'41053cbc1014e0fdd04d9510bf15c9896a9f752d';
abstract class _$BookmarksSearch extends $StreamNotifier<Set<String>> {
Stream<Set<String>> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<Set<String>>, Set<String>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<Set<String>>, Set<String>>,
AsyncValue<Set<String>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(BookmarkSearchResults)
final bookmarkSearchResultsProvider = BookmarkSearchResultsProvider._();
final class BookmarkSearchResultsProvider
extends $NotifierProvider<BookmarkSearchResults, List<BookmarkEntry>> {
BookmarkSearchResultsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bookmarkSearchResultsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bookmarkSearchResultsHash();
@$internal
@override
BookmarkSearchResults create() => BookmarkSearchResults();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<BookmarkEntry> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<BookmarkEntry>>(value),
);
}
}
String _$bookmarkSearchResultsHash() =>
r'49ebd21a137bc09bbd67b692aacb2554b8364564';
abstract class _$BookmarkSearchResults extends $Notifier<List<BookmarkEntry>> {
List<BookmarkEntry> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<List<BookmarkEntry>, List<BookmarkEntry>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<BookmarkEntry>, List<BookmarkEntry>>,
List<BookmarkEntry>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(bookmarks)
final bookmarksProvider = BookmarksFamily._();
final class BookmarksProvider<T extends BookmarkItem>
extends $FunctionalProvider<AsyncValue<T?>, AsyncValue<T?>, AsyncValue<T?>>
with $Provider<AsyncValue<T?>> {
BookmarksProvider._({
required BookmarksFamily super.from,
required (String, {bool hideEmptyRoots}) super.argument,
}) : super(
retry: null,
name: r'bookmarksProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bookmarksHash();
@override
String toString() {
return r'bookmarksProvider'
'<${T}>'
'$argument';
}
@$internal
@override
$ProviderElement<AsyncValue<T?>> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
AsyncValue<T?> create(Ref ref) {
final argument = this.argument as (String, {bool hideEmptyRoots});
return bookmarks<T>(
ref,
argument.$1,
hideEmptyRoots: argument.hideEmptyRoots,
);
}
$R _captureGenerics<$R>($R Function<T extends BookmarkItem>() cb) {
return cb<T>();
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<T?> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<T?>>(value),
);
}
@override
bool operator ==(Object other) {
return other is BookmarksProvider &&
other.runtimeType == runtimeType &&
other.argument == argument;
}
@override
int get hashCode {
return Object.hash(runtimeType, argument);
}
}
String _$bookmarksHash() => r'72b54c4ff18cfdb60824a57607628be6b61d25d4';
final class BookmarksFamily extends $Family {
BookmarksFamily._()
: super(
retry: null,
name: r'bookmarksProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
BookmarksProvider<T> call<T extends BookmarkItem>(
String entryGuid, {
bool hideEmptyRoots = false,
}) => BookmarksProvider<T>._(
argument: (entryGuid, hideEmptyRoots: hideEmptyRoots),
from: this,
);
@override
String toString() => r'bookmarksProvider';
/// {@macro riverpod.override_with}
Override overrideWith(
AsyncValue<T?> Function<T extends BookmarkItem>(
Ref ref,
(String, {bool hideEmptyRoots}) args,
)
create,
) => $FamilyOverride(
from: this,
createElement: (pointer) {
final provider = pointer.origin as BookmarksProvider;
return provider._captureGenerics(<T extends BookmarkItem>() {
provider as BookmarksProvider<T>;
final argument = provider.argument as (String, {bool hideEmptyRoots});
return provider
.$view(create: (ref) => create(ref, argument))
.$createElement(pointer);
});
},
);
}
@ProviderFor(SeamlessBookmarks)
final seamlessBookmarksProvider = SeamlessBookmarksFamily._();
final class SeamlessBookmarksProvider
extends $NotifierProvider<SeamlessBookmarks, AsyncValue<BookmarkItem?>> {
SeamlessBookmarksProvider._({
required SeamlessBookmarksFamily super.from,
required (String, {bool hideEmptyRoots}) super.argument,
}) : super(
retry: null,
name: r'seamlessBookmarksProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$seamlessBookmarksHash();
@override
String toString() {
return r'seamlessBookmarksProvider'
''
'$argument';
}
@$internal
@override
SeamlessBookmarks create() => SeamlessBookmarks();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<BookmarkItem?> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<BookmarkItem?>>(value),
);
}
@override
bool operator ==(Object other) {
return other is SeamlessBookmarksProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$seamlessBookmarksHash() => r'240b213fa8fe595781ccc608c5d551be54c31992';
final class SeamlessBookmarksFamily extends $Family
with
$ClassFamilyOverride<
SeamlessBookmarks,
AsyncValue<BookmarkItem?>,
AsyncValue<BookmarkItem?>,
AsyncValue<BookmarkItem?>,
(String, {bool hideEmptyRoots})
> {
SeamlessBookmarksFamily._()
: super(
retry: null,
name: r'seamlessBookmarksProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
SeamlessBookmarksProvider call(
String entryGuid, {
bool hideEmptyRoots = false,
}) => SeamlessBookmarksProvider._(
argument: (entryGuid, hideEmptyRoots: hideEmptyRoots),
from: this,
);
@override
String toString() => r'seamlessBookmarksProvider';
}
abstract class _$SeamlessBookmarks
extends $Notifier<AsyncValue<BookmarkItem?>> {
late final _$args = ref.$arg as (String, {bool hideEmptyRoots});
String get entryGuid => _$args.$1;
bool get hideEmptyRoots => _$args.hideEmptyRoots;
AsyncValue<BookmarkItem?> build(
String entryGuid, {
bool hideEmptyRoots = false,
});
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<AsyncValue<BookmarkItem?>, AsyncValue<BookmarkItem?>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<BookmarkItem?>, AsyncValue<BookmarkItem?>>,
AsyncValue<BookmarkItem?>,
Object?,
Object?
>;
element.handleCreate(
ref,
() => build(_$args.$1, hideEmptyRoots: _$args.hideEmptyRoots),
);
}
}
@@ -0,0 +1,196 @@
/*
* 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 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart';
part 'bookmarks.g.dart';
@Riverpod(keepAlive: true)
class BookmarksRepository extends _$BookmarksRepository {
final _service = GeckoBookmarksService();
late final _jsonUtils = BookmarkJSONUtils(_service);
late final _htmlUtils = BookmarkHTMLUtils(_service);
Future<void> addBookmark({
required String parentGuid,
required Uri url,
required String title,
int? position,
}) async {
await _service.addItem(parentGuid, url, title, position);
ref.invalidateSelf();
}
Future<void> addFolder({
required String parentGuid,
required String title,
int? position,
}) async {
await _service.addFolder(parentGuid, title, position);
ref.invalidateSelf();
}
Future<void> editBookmark({
required String guid,
String? title,
Uri? url,
String? parentGuid,
int? position,
}) async {
await _service.updateNode(
guid,
BookmarkInfo(
title: title,
url: url?.toString(),
parentGuid: parentGuid,
position: position,
),
);
ref.invalidateSelf();
}
Future<void> editFolder({
required String guid,
String? title,
String? parentGuid,
int? position,
}) async {
await _service.updateNode(
guid,
BookmarkInfo(title: title, parentGuid: parentGuid, position: position),
);
ref.invalidateSelf();
}
Future<void> delete(String guid) async {
await _service.deleteNode(guid);
ref.invalidateSelf();
}
Future<void> moveMany({
required Iterable<BookmarkItem> items,
required String targetParentGuid,
}) async {
for (final item in items) {
if (bookmarkRootIds.contains(item.guid)) {
logger.w('Skipping move of root folder: ${item.guid}');
continue;
}
if (item.parentGuid == targetParentGuid) continue;
await _service.updateNode(
item.guid,
BookmarkInfo(parentGuid: targetParentGuid),
);
}
ref.invalidateSelf();
}
Future<void> deleteMany(Iterable<String> guids) async {
for (final guid in guids) {
if (bookmarkRootIds.contains(guid)) {
logger.w('Skipping delete of root folder: $guid');
continue;
}
await _service.deleteNode(guid);
}
ref.invalidateSelf();
}
Future<void> flattenFolder({required BookmarkFolder folder}) async {
if (folder.parentGuid == null || bookmarkRootIds.contains(folder.guid)) {
logger.w('Cannot flatten root or parentless folder: ${folder.guid}');
return;
}
// Fetch the full folder tree from storage to avoid operating on a
// filtered subset (e.g. when search is active), which would silently
// delete children that were not moved.
final fullNode = await _service.getTree(folder.guid);
final children = fullNode?.children;
if (children != null) {
for (final child in children) {
await _service.updateNode(
child.guid,
BookmarkInfo(parentGuid: folder.parentGuid),
);
}
}
await _service.deleteNode(folder.guid);
ref.invalidateSelf();
}
/// Returns the GUIDs of all descendant folders of [guid] by fetching the
/// full subtree from storage. This is safe to call even when the UI tree is
/// filtered (e.g. during search), unlike the pure-utility
/// [collectDescendantFolderGuids] which only walks the in-memory tree.
Future<Set<String>> getDescendantFolderGuids(String guid) async {
final node = await _service.getTree(guid, recursive: true);
if (node == null) return const {};
final result = <String>{};
void collect(BookmarkNode n) {
for (final child in n.children ?? const <BookmarkNode>[]) {
if (child.type == BookmarkNodeType.folder) {
result.add(child.guid);
collect(child);
}
}
}
collect(node);
return result;
}
Future<void> eraseEverything(BookmarkRoot root) async {
await _service.eraseEverything(root);
ref.invalidateSelf();
}
Future<int> importFromJSON(String jsonString, {bool replace = false}) async {
final count = await _jsonUtils.importFromJSON(jsonString, replace: replace);
ref.invalidateSelf();
return count;
}
Future<int> importFromHTML(String htmlString, {bool replace = false}) async {
final count = await _htmlUtils.importFromHTML(htmlString, replace: replace);
ref.invalidateSelf();
return count;
}
Future<Map<String, dynamic>?> exportToJson({
required BookmarkRoot root,
}) async {
return await _jsonUtils.exportToJson(root: root);
}
Future<String> exportToHTML({required BookmarkRoot root}) async {
return await _htmlUtils.exportToHTML(root: root);
}
@override
Future<BookmarkItem?> build() async {
final node = await _service.getTree(BookmarkRoot.root.id, recursive: true);
return node.mapNotNull(BookmarkItem.parseRecursive);
}
}
@@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bookmarks.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BookmarksRepository)
final bookmarksRepositoryProvider = BookmarksRepositoryProvider._();
final class BookmarksRepositoryProvider
extends $AsyncNotifierProvider<BookmarksRepository, BookmarkItem?> {
BookmarksRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bookmarksRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bookmarksRepositoryHash();
@$internal
@override
BookmarksRepository create() => BookmarksRepository();
}
String _$bookmarksRepositoryHash() =>
r'2169d5b354c4a22192096451c96ab1490cf55ab4';
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
FutureOr<BookmarkItem?> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<BookmarkItem?>, BookmarkItem?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<BookmarkItem?>, BookmarkItem?>,
AsyncValue<BookmarkItem?>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,162 @@
/*
* 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 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
/// Recursively sorts a bookmark tree by the given sort type.
/// Root-level built-in folders are kept in their canonical order.
BookmarkItem sortBookmarkTree(
BookmarkItem item,
BookmarkSortType sortType, {
bool isRoot = false,
}) {
if (sortType == BookmarkSortType.manual) return item;
if (item is BookmarkFolder && item.children != null) {
final sortedChildren = item.children!.map((child) {
return sortBookmarkTree(child, sortType);
}).toList();
// At root level, keep built-in root folders pinned in original order
if (isRoot) {
final rootFolders = <BookmarkItem>[];
final nonRootItems = <BookmarkItem>[];
for (final child in sortedChildren) {
if (bookmarkRootIds.contains(child.guid)) {
rootFolders.add(child);
} else {
nonRootItems.add(child);
}
}
nonRootItems.sort((a, b) => compareBookmarkItems(a, b, sortType));
return BookmarkFolder(
guid: item.guid,
parentGuid: item.parentGuid,
title: item.title,
position: item.position,
dateAdded: item.dateAdded,
children: [...rootFolders, ...nonRootItems],
);
}
sortedChildren.sort((a, b) => compareBookmarkItems(a, b, sortType));
return BookmarkFolder(
guid: item.guid,
parentGuid: item.parentGuid,
title: item.title,
position: item.position,
dateAdded: item.dateAdded,
children: sortedChildren,
);
}
return item;
}
/// Collects all descendant folder GUIDs from a folder (not including the folder itself).
Set<String> collectDescendantFolderGuids(BookmarkFolder folder) {
final result = <String>{};
if (folder.children != null) {
for (final child in folder.children!) {
if (child is BookmarkFolder) {
result.add(child.guid);
result.addAll(collectDescendantFolderGuids(child));
}
}
}
return result;
}
/// Resolves BookmarkItems from a tree by their GUIDs.
List<BookmarkItem> resolveSelectedItems(BookmarkItem root, Set<String> guids) {
final result = <BookmarkItem>[];
_collectByGuids(root, guids, result);
return result;
}
void _collectByGuids(
BookmarkItem item,
Set<String> guids,
List<BookmarkItem> result,
) {
if (guids.contains(item.guid)) {
result.add(item);
}
if (item is BookmarkFolder && item.children != null) {
for (final child in item.children!) {
_collectByGuids(child, guids, result);
}
}
}
/// Whether a folder can be flattened (non-root, has a parent, has children).
bool canFlattenFolder(BookmarkFolder folder) {
return folder.parentGuid != null &&
!bookmarkRootIds.contains(folder.guid) &&
folder.children != null &&
folder.children!.isNotEmpty;
}
/// Normalizes a selection set: removes items that are descendants of selected folders.
/// This prevents double-applying moves when both a folder and its children are selected.
Set<String> normalizeSelection(BookmarkItem root, Set<String> selectedGuids) {
final items = resolveSelectedItems(root, selectedGuids);
final folderGuidsToRemove = <String>{};
for (final item in items) {
if (item is BookmarkFolder) {
_collectAllDescendantGuids(item, folderGuidsToRemove);
}
}
return selectedGuids.difference(folderGuidsToRemove);
}
void _collectAllDescendantGuids(BookmarkFolder folder, Set<String> result) {
if (folder.children != null) {
for (final child in folder.children!) {
result.add(child.guid);
if (child is BookmarkFolder) {
_collectAllDescendantGuids(child, result);
}
}
}
}
/// Returns GUIDs of all bookmark entries matching [url] in the tree.
List<String> bookmarkGuidsForUrl(BookmarkItem? root, Uri? url) {
final result = <String>[];
if (root == null || url == null) return result;
void collect(BookmarkItem item) {
if (item is BookmarkEntry && item.url == url) {
result.add(item.guid);
}
if (item is BookmarkFolder) {
for (final child in item.children ?? const <BookmarkItem>[]) {
collect(child);
}
}
}
collect(root);
return result;
}
@@ -0,0 +1,47 @@
/*
* 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 'package:flutter/material.dart';
Future<bool?> showDeleteBookmarkDialog(BuildContext context) {
return showDialog<bool?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
icon: const Icon(Icons.warning),
title: const Text('Delete Bookmark'),
content: const Text('Are you sure you want to delete this Bookmark?'),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context, false);
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context, true);
},
child: const Text('Delete'),
),
],
);
},
);
}
@@ -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/>.
*/
import 'package:flutter/material.dart';
Future<bool?> showDeleteFolderDialog(BuildContext context) {
return showDialog<bool?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
icon: const Icon(Icons.warning),
title: const Text('Delete Folder'),
content: const Text(
'Are you sure you want to delete this Folder including all bookmarks?',
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context, false);
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context, true);
},
child: const Text('Delete'),
),
],
);
},
);
}
@@ -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/>.
*/
import 'package:flutter/material.dart';
Future<bool?> showImportBookmarksDialog(BuildContext context) {
return showDialog<bool?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Import Bookmarks'),
content: const Text(
'Do you want to erase all existing bookmarks before importing?\n\n'
'Choose "Replace" to delete existing bookmarks, or "Merge" to keep them.',
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context, false);
},
child: const Text('Merge'),
),
TextButton(
onPressed: () {
Navigator.pop(context, true);
},
child: const Text('Replace'),
),
],
);
},
);
}
@@ -0,0 +1,102 @@
/*
* 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 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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:weblibre/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart';
/// Shows a bottom sheet for selecting a bookmark folder destination (for move operations).
///
/// Returns the selected folder GUID, or null if cancelled.
Future<String?> showSelectBookmarkFolderDialog(
BuildContext context, {
Set<String> excludeFolderGuids = const {},
String? initialFolderGuid,
}) {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
builder: (context) => _SelectBookmarkFolderSheet(
excludeFolderGuids: excludeFolderGuids,
initialFolderGuid: initialFolderGuid,
),
);
}
class _SelectBookmarkFolderSheet extends HookConsumerWidget {
final Set<String> excludeFolderGuids;
final String? initialFolderGuid;
const _SelectBookmarkFolderSheet({
required this.excludeFolderGuids,
this.initialFolderGuid,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedGuid = useState(initialFolderGuid ?? BookmarkRoot.mobile.id);
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Move to Folder',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.5,
),
child: SingleChildScrollView(
child: FolderTreePicker(
selectedFolderGuid: selectedGuid,
excludeFolderGuids: excludeFolderGuids,
entryGuid: BookmarkRoot.root.id,
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => context.pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () => context.pop(selectedGuid.value),
child: const Text('Move'),
),
],
),
],
),
),
);
}
}
@@ -0,0 +1,208 @@
/*
* 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 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_bookmark_dialog.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart';
import 'package:weblibre/utils/form_validators.dart';
import 'package:weblibre/utils/uri_input_parser.dart';
class BookmarkEntryEditScreen extends HookConsumerWidget {
final BookmarkInfo? initialInfo;
final BookmarkEntry? exisitingEntry;
const BookmarkEntryEditScreen({
super.key,
required this.exisitingEntry,
required this.initialInfo,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final nameTextController = useTextEditingController(
text: initialInfo?.title ?? exisitingEntry?.title,
);
final urlTextController = useTextEditingController(
text: initialInfo?.url ?? exisitingEntry?.url.toString(),
);
final parentGuid = useState(
initialInfo?.parentGuid ??
exisitingEntry?.parentGuid ??
BookmarkRoot.mobile.id,
);
final addToTop = useState(false);
return Scaffold(
appBar: AppBar(
title: (exisitingEntry != null)
? const Text('Edit Bookmark')
: const Text('Create Bookmark'),
actions: [
IconButton(
onPressed: () async {
if (formKey.currentState?.validate() ?? false) {
var newUrl = parseValidatedUrl(
urlTextController.text,
eagerParsing: true,
onlyHttpProtocol: true,
);
if (newUrl == null) {
return;
}
newUrl = redactUriCredentials(newUrl);
if (exisitingEntry != null) {
await ref
.read(bookmarksRepositoryProvider.notifier)
.editBookmark(
guid: exisitingEntry!.guid,
title:
(nameTextController.text != exisitingEntry!.title)
? nameTextController.text
: null,
parentGuid:
(parentGuid.value != exisitingEntry!.parentGuid)
? parentGuid.value
: null,
url: (newUrl != exisitingEntry!.url) ? newUrl : null,
);
if (context.mounted) {
context.pop();
}
} else {
await ref
.read(bookmarksRepositoryProvider.notifier)
.addBookmark(
parentGuid: parentGuid.value,
title: nameTextController.text,
url: newUrl,
position: addToTop.value ? 0 : null,
);
if (context.mounted) {
context.pop();
}
}
}
},
icon: const Icon(Icons.check),
),
],
),
body: SafeArea(
child: Form(
key: formKey,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: ListView(
children: [
TextFormField(
controller: nameTextController,
decoration: const InputDecoration(
label: Text('Name'),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
minLines: 1,
maxLines: 3,
validator: validateRequired,
),
const SizedBox(height: 8),
TextFormField(
controller: urlTextController,
keyboardType: TextInputType.url,
minLines: 1,
maxLines: 10,
decoration: const InputDecoration(
label: Text('URL'),
hintText: 'https://example.com/',
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: (value) {
return validateUrl(
value,
onlyHttpProtocol: true,
eagerParsing: true,
);
},
),
const SizedBox(height: 16),
FolderTreePicker(
selectedFolderGuid: parentGuid,
entryGuid: BookmarkRoot.root.id,
),
if (exisitingEntry == null) ...[
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Add to top'),
value: addToTop.value,
onChanged: (value) => addToTop.value = value,
),
],
const SizedBox(height: 16),
if (exisitingEntry != null)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
side: BorderSide(
color: Theme.of(context).colorScheme.error,
),
foregroundColor: Theme.of(context).colorScheme.error,
iconColor: Theme.of(context).colorScheme.error,
),
label: const Text('Delete'),
icon: const Icon(MdiIcons.bookmarkRemove),
onPressed: () async {
final result = await showDeleteBookmarkDialog(context);
if (result == true) {
await ref
.read(bookmarksRepositoryProvider.notifier)
.delete(exisitingEntry!.guid);
if (context.mounted) {
context.pop();
}
}
},
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,172 @@
/*
* 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 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_folder_dialog.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart';
import 'package:weblibre/utils/form_validators.dart';
class BookmarkFolderEditScreen extends HookConsumerWidget {
final String? parentGuid;
final BookmarkFolder? folder;
const BookmarkFolderEditScreen({
super.key,
required this.folder,
this.parentGuid,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final nameTextController = useTextEditingController(text: folder?.title);
final currentParentGuid = useState(
parentGuid ?? folder?.parentGuid ?? BookmarkRoot.mobile.id,
);
final addToTop = useState(false);
// Check if this is a bookmark root folder (these cannot be moved)
final isBookmarkRoot =
folder != null && bookmarkRootIds.contains(folder!.guid);
return Scaffold(
appBar: AppBar(
title: (folder != null)
? const Text('Edit Folder')
: const Text('Create Folder'),
actions: [
IconButton(
onPressed: () async {
if (formKey.currentState?.validate() ?? false) {
if (folder != null) {
await ref
.read(bookmarksRepositoryProvider.notifier)
.editFolder(
guid: folder!.guid,
title: (nameTextController.text != folder!.title)
? nameTextController.text
: null,
parentGuid:
(!isBookmarkRoot &&
currentParentGuid.value != folder!.parentGuid)
? currentParentGuid.value
: null,
);
if (context.mounted) {
context.pop();
}
} else {
await ref
.read(bookmarksRepositoryProvider.notifier)
.addFolder(
parentGuid: currentParentGuid.value,
title: nameTextController.text,
position: addToTop.value ? 0 : null,
);
if (context.mounted) {
context.pop();
}
}
}
},
icon: const Icon(Icons.check),
),
],
),
body: SafeArea(
child: Form(
key: formKey,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: ListView(
children: [
TextFormField(
controller: nameTextController,
decoration: const InputDecoration(
label: Text('Name'),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: validateRequired,
),
const SizedBox(height: 16),
if (!isBookmarkRoot) ...[
FolderTreePicker(
selectedFolderGuid: currentParentGuid,
excludeFolderGuids: folder != null
? {folder!.guid}
: const {},
entryGuid: BookmarkRoot.root.id,
),
if (folder == null) ...[
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Add to top'),
value: addToTop.value,
onChanged: (value) => addToTop.value = value,
),
],
const SizedBox(height: 16),
],
if (folder != null)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
side: BorderSide(
color: Theme.of(context).colorScheme.error,
),
foregroundColor: Theme.of(context).colorScheme.error,
iconColor: Theme.of(context).colorScheme.error,
),
label: const Text('Delete'),
icon: const Icon(Icons.delete),
onPressed: () async {
final result = await showDeleteFolderDialog(context);
if (result == true) {
await ref
.read(bookmarksRepositoryProvider.notifier)
.delete(folder!.guid);
if (context.mounted) {
context.pop();
}
}
},
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,164 @@
/*
* 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 'package:animated_tree_view/animated_tree_view.dart';
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:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
/// A widget that displays a tree view of bookmark folders and allows the user
/// to select a parent folder.
///
/// When editing a folder, pass [excludeFolderGuids] to prevent selecting the
/// folders or their descendants as the parent (which would create a circular reference).
class FolderTreePicker extends HookConsumerWidget {
/// The currently selected folder GUID
final ValueNotifier<String> selectedFolderGuid;
/// Optional folder GUIDs to exclude from the tree (along with their descendants).
/// Used when editing/moving folders to prevent circular parent relationships.
final Set<String> excludeFolderGuids;
final String entryGuid;
const FolderTreePicker({
required this.selectedFolderGuid,
required this.entryGuid,
this.excludeFolderGuids = const {},
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final treeKey = useMemoized(() => GlobalKey<TreeViewState>());
final folderList = ref.watch(bookmarksProvider<BookmarkFolder>(entryGuid));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Folder', style: Theme.of(context).textTheme.labelMedium),
folderList.when(
skipLoadingOnReload: true,
data: (list) {
TreeNode<BookmarkFolder> addChildren(
TreeNode<BookmarkFolder>? parent,
BookmarkFolder item,
) {
final node = TreeNode(key: item.guid, data: item, parent: parent);
final targetNode = (parent?..add(node)) ?? node;
if (item.children != null) {
for (final child in item.children!) {
// Skip excluded folders and their descendants
if (child is BookmarkFolder &&
!excludeFolderGuids.contains(child.guid)) {
addChildren(node, child);
}
}
}
return targetNode;
}
final root = (list != null)
? addChildren(null, list)
: TreeNode<BookmarkFolder>.root();
return TreeView.simple(
key: treeKey,
tree: root,
shrinkWrap: true,
showRootNode: entryGuid != BookmarkRoot.root.id,
onTreeReady: (controller) {
controller.expandAllChildren(root, recursive: true);
},
expansionIndicatorBuilder: (context, tree) =>
ChevronIndicator.upDown(
tree: tree,
padding: const EdgeInsets.symmetric(
vertical: 16.0,
horizontal: 12.0,
),
),
builder: (context, item) {
final isSelected = item.data?.guid == selectedFolderGuid.value;
// BookmarkRoot.root cannot be selected as a parent
final isRootFolder = item.data?.guid == BookmarkRoot.root.id;
return Padding(
padding: const EdgeInsets.only(right: 42.0),
child: switch (item.data) {
final BookmarkFolder folder => ListTile(
key: ValueKey(folder.guid),
contentPadding: EdgeInsets.zero,
selected: isSelected,
enabled: !isRootFolder,
leading: (item.isExpanded)
? const Icon(MdiIcons.folderOpen)
: const Icon(MdiIcons.folder),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelected) const Icon(Icons.check),
IconButton(
onPressed: () async {
await BookmarkFolderAddRoute(
parentGuid: folder.guid,
).push(context);
},
icon: const Icon(MdiIcons.folderPlus),
),
],
),
title: Text(folder.title),
onTap: !isRootFolder
? () {
selectedFolderGuid.value = folder.guid;
}
: null,
),
null => const SizedBox.shrink(),
},
);
},
);
},
error: (error, stackTrace) => Center(
child: FailureWidget(
title: 'Failed to load Bookmark Folders',
exception: error,
onRetry: () {
ref.invalidate(bookmarksProvider<BookmarkFolder>(entryGuid));
},
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
],
);
}
}
@@ -0,0 +1,594 @@
/*
* 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 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
import 'package:weblibre/core/logger.dart';
const _containerNormal = 0;
const _containerToolbar = 1;
const _containerMenu = 2;
const _containerUnfiled = 3;
const _containerPlaces = 4;
const _exportIndent = ' ';
class _Frame {
final Map<String, dynamic> folder;
int containerNesting = 0;
int lastContainerType = _containerNormal;
String previousText = '';
bool inDescription = false;
String? previousLink;
Map<String, dynamic>? previousItem;
DateTime? previousDateAdded;
DateTime? previousLastModifiedDate;
_Frame(this.folder);
}
class BookmarkHTMLUtils {
final GeckoBookmarksService _service;
BookmarkHTMLUtils(this._service);
/// Import bookmarks from HTML string
Future<int> importFromHTML(String htmlString, {bool replace = false}) async {
final importer = _BookmarkImporter(_service, replace);
return await importer.importFromHTML(htmlString);
}
/// Export bookmarks to HTML string
Future<String> exportToHTML({required BookmarkRoot root}) async {
final tree = await _service.getTree(root.id, recursive: true);
if (tree == null) {
throw Exception('Failed to get bookmarks tree');
}
final exporter = _BookmarkExporter(tree);
return exporter.exportToHTML();
}
}
class _BookmarkImporter {
final GeckoBookmarksService _service;
final bool _isImportDefaults;
final Map<String, dynamic> _bookmarkTree;
final List<_Frame> _frames = [];
_BookmarkImporter(this._service, this._isImportDefaults)
: _bookmarkTree = {
'type': BookmarkNodeType.folder.index,
'guid': BookmarkRoot.menu.id,
'children': <Map<String, dynamic>>[],
} {
_frames.add(_Frame(_bookmarkTree));
}
_Frame get _curFrame => _frames.last;
Future<int> importFromHTML(String htmlString) async {
final document = html_parser.parse(htmlString);
_walkTreeForImport(document.body);
return await _importBookmarks();
}
dom.Node? _nextSibling(dom.Node node) {
final parent = node.parent;
if (parent == null) return null;
final siblings = parent.nodes;
final index = siblings.indexOf(node);
if (index != -1 && index + 1 < siblings.length) {
return siblings[index + 1];
}
return null;
}
void _walkTreeForImport(dom.Node? node) {
if (node == null) return;
dom.Node? current = node;
dom.Node? next;
for (;;) {
if (current?.nodeType == dom.Node.ELEMENT_NODE) {
_openContainer(current! as dom.Element);
} else if (current?.nodeType == dom.Node.TEXT_NODE) {
_appendText(current!.text ?? '');
}
if ((next = current?.firstChild) != null) {
current = next;
continue;
}
for (;;) {
if (current?.nodeType == dom.Node.ELEMENT_NODE) {
_closeContainer(current! as dom.Element);
}
if (current == node) return;
if ((next = _nextSibling(current!)) != null) {
current = next;
break;
}
current = current.parentNode;
}
}
}
void _openContainer(dom.Element element) {
switch (element.localName) {
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
_handleHeadBegin(element);
case 'a':
_handleLinkBegin(element);
case 'dl':
case 'ul':
case 'menu':
_handleContainerBegin();
case 'dd':
_curFrame.inDescription = true;
case 'hr':
_handleSeparator();
}
}
void _closeContainer(dom.Element element) {
final frame = _curFrame;
if (frame.inDescription) {
frame.previousText = '';
frame.inDescription = false;
}
switch (element.localName) {
case 'dl':
case 'ul':
case 'menu':
_handleContainerEnd();
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
_handleHeadEnd();
case 'a':
_handleLinkEnd();
}
}
void _appendText(String str) {
_curFrame.previousText += str;
}
void _handleHeadBegin(dom.Element element) {
final frame = _curFrame;
frame.previousLink = null;
frame.lastContainerType = _containerNormal;
if (frame.containerNesting == 0 && _frames.length > 1) {
_frames.removeLast();
}
if (element.attributes.containsKey('personal_toolbar_folder')) {
if (_isImportDefaults) {
frame.lastContainerType = _containerToolbar;
}
} else if (element.attributes.containsKey('bookmarks_menu')) {
if (_isImportDefaults) {
frame.lastContainerType = _containerMenu;
}
} else if (element.attributes.containsKey('unfiled_bookmarks_folder')) {
if (_isImportDefaults) {
frame.lastContainerType = _containerUnfiled;
}
} else if (element.attributes.containsKey('places_root')) {
if (_isImportDefaults) {
frame.lastContainerType = _containerPlaces;
}
} else {
final addDate = element.attributes['add_date'];
if (addDate != null) {
frame.previousDateAdded = _convertImportedDateToInternalDate(addDate);
}
final modDate = element.attributes['last_modified'];
if (modDate != null) {
frame.previousLastModifiedDate = _convertImportedDateToInternalDate(
modDate,
);
}
}
_curFrame.previousText = '';
}
void _handleLinkBegin(dom.Element element) {
final frame = _curFrame;
frame.previousItem = null;
frame.previousText = '';
final href = element.attributes['href']?.trim();
final dateAdded = element.attributes['add_date']?.trim();
final lastModified = element.attributes['last_modified']?.trim();
final tags = element.attributes['tags']?.trim();
final keyword = element.attributes['shortcuturl']?.trim();
final postData = element.attributes['post_data']?.trim();
final lastCharset = element.attributes['last_charset']?.trim();
if (href == null || href.isEmpty) {
frame.previousLink = null;
return;
}
try {
final uri = Uri.parse(href);
if (!uri.hasScheme) {
frame.previousLink = null;
return;
}
frame.previousLink = uri.toString();
} catch (e) {
frame.previousLink = null;
return;
}
final bookmark = <String, dynamic>{'url': frame.previousLink};
if (dateAdded != null) {
bookmark['dateAdded'] = _convertImportedDateToInternalDate(
dateAdded,
).millisecondsSinceEpoch;
}
if (lastModified != null) {
bookmark['lastModified'] = _convertImportedDateToInternalDate(
lastModified,
).millisecondsSinceEpoch;
}
if (dateAdded == null && lastModified != null) {
bookmark['dateAdded'] = bookmark['lastModified'];
}
if (tags != null && tags.isNotEmpty) {
bookmark['tags'] = tags;
}
if (keyword != null && keyword.isNotEmpty) {
bookmark['keyword'] = keyword;
}
if (postData != null && postData.isNotEmpty) {
bookmark['postData'] = postData;
}
if (lastCharset != null && lastCharset.isNotEmpty) {
bookmark['charset'] = lastCharset;
}
(frame.folder['children'] as List).add(bookmark);
frame.previousItem = bookmark;
}
void _handleContainerBegin() {
_curFrame.containerNesting++;
}
void _handleContainerEnd() {
final frame = _curFrame;
if (frame.containerNesting > 0) {
frame.containerNesting--;
}
if (_frames.length > 1 && frame.containerNesting == 0) {
_frames.removeLast();
}
}
void _handleHeadEnd() {
_newFrame();
}
void _handleLinkEnd() {
final frame = _curFrame;
frame.previousText = frame.previousText.trim();
if (frame.previousItem != null) {
frame.previousItem!['title'] = frame.previousText;
}
frame.previousText = '';
}
void _handleSeparator() {
final frame = _curFrame;
final separator = <String, dynamic>{
'type': BookmarkNodeType.separator.index,
};
(frame.folder['children'] as List).add(separator);
frame.previousItem = separator;
}
void _newFrame() {
final frame = _curFrame;
final containerTitle = frame.previousText;
frame.previousText = '';
final containerType = frame.lastContainerType;
final folder = <String, dynamic>{
'children': <Map<String, dynamic>>[],
'type': BookmarkNodeType.folder.index,
};
switch (containerType) {
case _containerNormal:
folder['title'] = containerTitle;
case _containerPlaces:
folder['guid'] = BookmarkRoot.root.id;
case _containerMenu:
folder['guid'] = BookmarkRoot.menu.id;
case _containerUnfiled:
folder['guid'] = BookmarkRoot.unfiled.id;
case _containerToolbar:
folder['guid'] = BookmarkRoot.toolbar.id;
}
(frame.folder['children'] as List).add(folder);
if (frame.previousDateAdded != null) {
folder['dateAdded'] = frame.previousDateAdded!.millisecondsSinceEpoch;
frame.previousDateAdded = null;
}
if (frame.previousLastModifiedDate != null) {
folder['lastModified'] =
frame.previousLastModifiedDate!.millisecondsSinceEpoch;
frame.previousLastModifiedDate = null;
}
if (!folder.containsKey('dateAdded') &&
folder.containsKey('lastModified')) {
folder['dateAdded'] = folder['lastModified'];
}
frame.previousItem = folder;
_frames.add(_Frame(folder));
}
DateTime _convertImportedDateToInternalDate(String seconds) {
try {
final parsed = int.tryParse(seconds);
if (parsed != null) {
return DateTime.fromMillisecondsSinceEpoch(parsed * 1000);
}
} catch (e) {
// Fall through
}
return DateTime.now();
}
List<Map<String, dynamic>> _getBookmarkTrees() {
if (!_isImportDefaults) {
return [_bookmarkTree];
}
final bookmarkTrees = <Map<String, dynamic>>[_bookmarkTree];
final children = _bookmarkTree['children'] as List<Map<String, dynamic>>;
_bookmarkTree['children'] = children.where((child) {
final guid = child['guid'] as String?;
if (guid != null && bookmarkRootIds.contains(guid)) {
bookmarkTrees.add(child);
return false;
}
return true;
}).toList();
return bookmarkTrees;
}
Future<int> _importBookmarks() async {
if (_isImportDefaults) {
// Delete bookmarks from each root folder (except root itself to avoid errors)
for (final root in BookmarkRoot.values) {
if (root != BookmarkRoot.root) {
await _service.eraseEverything(root);
}
}
}
final bookmarkTrees = _getBookmarkTrees();
int bookmarkCount = 0;
for (final tree in bookmarkTrees) {
final children = tree['children'] as List?;
if (children == null || children.isEmpty) continue;
bookmarkCount += await _insertTree(tree);
}
return bookmarkCount;
}
Future<int> _insertTree(Map<String, dynamic> node) async {
int count = 0;
final children = node['children'] as List?;
if (children == null || children.isEmpty) return 0;
final parentGuid = node['guid'] as String;
for (int i = 0; i < children.length; i++) {
final child = children[i] as Map<String, dynamic>;
final type = child['type'] as int? ?? BookmarkNodeType.item.index;
if (type == BookmarkNodeType.item.index) {
final url = child['url'] as String?;
final title = child['title'] as String? ?? '';
if (url != null && url.isNotEmpty) {
try {
final uri = Uri.parse(url);
if (uri.hasScheme) {
await _service.addItem(parentGuid, uri, title, i);
count++;
}
} catch (e) {
logger.e('Failed to import bookmark "$title": $e');
}
}
} else if (type == BookmarkNodeType.folder.index) {
final title = child['title'] as String? ?? '';
try {
final newGuid = await _service.addFolder(parentGuid, title, i);
child['guid'] = newGuid;
count += await _insertTree(child);
} catch (e) {
logger.e('Failed to import folder "$title": $e');
}
}
}
return count;
}
}
class _BookmarkExporter {
final BookmarkNode _root;
final StringBuffer _buffer = StringBuffer();
_BookmarkExporter(this._root);
String exportToHTML() {
_writeHeader();
_writeContainer(_root);
return _buffer.toString();
}
void _write(String text) {
_buffer.write(text);
}
void _writeLine(String text) {
_buffer.writeln(text);
}
void _writeHeader() {
_writeLine('<!DOCTYPE NETSCAPE-Bookmark-file-1>');
_writeLine('<!-- This is an automatically generated file.');
_writeLine(' It will be read and overwritten.');
_writeLine(' DO NOT EDIT! -->');
_writeLine(
'<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">',
);
_writeLine('<meta http-equiv="Content-Security-Policy"');
_writeLine(
' content="default-src \'self\'; script-src \'none\'; img-src data: *; object-src \'none\'"></meta>',
);
_writeLine('<TITLE>Bookmarks</TITLE>');
}
void _writeContainer(BookmarkNode item, [String indent = '']) {
if (item.guid == _root.guid) {
_writeLine('<H1>${_escapeHtml(item.title ?? 'Bookmarks')}</H1>');
_writeLine('');
} else {
_write('$indent<DT><H3');
_writeDateAttributes(item);
if (item.guid == BookmarkRoot.toolbar.id) {
_write(' PERSONAL_TOOLBAR_FOLDER="true"');
} else if (item.guid == BookmarkRoot.unfiled.id) {
_write(' UNFILED_BOOKMARKS_FOLDER="true"');
}
_writeLine('>${_escapeHtml(item.title ?? '')}</H3>');
}
_writeLine('$indent<DL><p>');
if (item.children != null) {
_writeContainerContents(item, indent);
}
if (item.guid == _root.guid) {
_writeLine('$indent</DL>');
} else {
_writeLine('$indent</DL><p>');
}
}
void _writeContainerContents(BookmarkNode item, String indent) {
final localIndent = indent + _exportIndent;
for (final child in item.children!) {
if (child.type == BookmarkNodeType.folder) {
_writeContainer(child, localIndent);
} else if (child.type == BookmarkNodeType.separator) {
_writeSeparator(child, localIndent);
} else {
_writeItem(child, localIndent);
}
}
}
void _writeSeparator(BookmarkNode item, String indent) {
_write('$indent<HR');
if (item.title != null && item.title!.isNotEmpty) {
_write(' NAME="${_escapeHtml(item.title!)}"');
}
_writeLine('>');
}
void _writeItem(BookmarkNode item, String indent) {
if (item.url == null || item.url!.isEmpty) return;
try {
Uri.parse(item.url!);
} catch (e) {
return;
}
_write('$indent<DT><A HREF="${_escapeUrl(item.url!)}"');
_writeDateAttributes(item);
_writeLine('>${_escapeHtml(item.title ?? '')}</A>');
}
void _writeDateAttributes(BookmarkNode item) {
// Convert from microseconds to seconds (UNIX timestamp)
if (item.dateAdded > 0) {
_write(' ADD_DATE="${item.dateAdded ~/ 1000000}"');
}
if (item.lastModified > 0) {
_write(' LAST_MODIFIED="${item.lastModified ~/ 1000000}"');
}
}
String _escapeHtml(String text) {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
String _escapeUrl(String text) {
return text.replaceAll('"', '%22');
}
}
@@ -0,0 +1,376 @@
/*
* 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/>.
*/
// ignore_for_file: unnecessary_raw_strings
import 'dart:convert';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/utils/uri_input_parser.dart';
class BookmarkJSONUtils {
final GeckoBookmarksService _service;
BookmarkJSONUtils(this._service);
/// Import bookmarks from JSON string
Future<int> importFromJSON(String jsonString, {bool replace = false}) async {
try {
final data = jsonDecode(jsonString);
if (data is! Map<String, dynamic>) {
throw Exception('Invalid JSON format');
}
final children = data['children'] as List?;
if (children == null || children.isEmpty) {
return 0;
}
return await _import(data, replace: replace);
} catch (ex) {
logger.e('Failed to import bookmarks: $ex');
rethrow;
}
}
/// Export bookmarks to JSON
Future<Map<String, dynamic>?> exportToJson({
required BookmarkRoot root,
}) async {
final tree = await _service.getTree(root.id, recursive: true);
if (tree == null) {
throw Exception('Failed to get bookmarks tree');
}
return _nodeToJson(tree, isRoot: true);
}
/// Import implementation
Future<int> _import(
Map<String, dynamic> rootNode, {
required bool replace,
}) async {
final nodes =
(rootNode['children'] as List?)
?.whereType<Map<String, dynamic>>()
.where(
(node) =>
node['root'] != 'tagsFolder' &&
node['guid'] != 'tags________',
)
.toList() ??
[];
if (nodes.isEmpty) {
return 0;
}
// If replacing, erase existing bookmarks first
if (replace) {
// Delete bookmarks from each root folder (except root itself to avoid errors)
for (final root in BookmarkRoot.values) {
if (root != BookmarkRoot.root) {
await _service.eraseEverything(root);
}
}
}
final folderIdToGuidMap = <String, String>{};
// Translate tree types and build folder map
for (final node in nodes) {
if (node['children'] == null || (node['children'] as List).isEmpty) {
continue;
}
final folders = _translateTreeTypes(node);
folderIdToGuidMap.addAll(folders);
}
int bookmarkCount = 0;
// Insert nodes
for (final node in nodes) {
if (node['children'] == null || (node['children'] as List).isEmpty) {
continue;
}
final guid = node['guid'] as String?;
if (guid == null || !bookmarkRootIds.contains(guid)) {
continue;
}
_fixupSearchQueries(node, folderIdToGuidMap);
// Insert the tree recursively
bookmarkCount += await _insertTree(node, folderIdToGuidMap);
}
return bookmarkCount;
}
/// Recursively insert bookmark tree
Future<int> _insertTree(
Map<String, dynamic> node,
Map<String, String> folderIdToGuidMap,
) async {
int count = 0;
final children = node['children'] as List?;
if (children == null || children.isEmpty) {
return 0;
}
final parentGuid = node['guid'] as String;
for (int i = 0; i < children.length; i++) {
final child = children[i] as Map<String, dynamic>;
final type = _getNodeType(child);
if (type == BookmarkNodeType.item) {
final url = _getNodeUrl(child);
final title = child['title'] as String? ?? '';
if (url != null && url.isNotEmpty) {
try {
// Validate URL before inserting
final uri = Uri.tryParse(url);
if (uri != null && uri.hasScheme) {
await _service.addItem(parentGuid, uri, title, i);
count++;
} else {
final parsed = Uri.tryParse(url);
final redacted = parsed != null
? redactUriCredentials(parsed)
: url;
logger.w('Skipping invalid URL: $redacted');
}
} catch (e) {
logger.e('Failed to import bookmark "$title": $e');
}
}
} else if (type == BookmarkNodeType.folder) {
final title = child['title'] as String? ?? '';
try {
final newGuid = await _service.addFolder(parentGuid, title, i);
child['guid'] = newGuid;
// Recursively insert children
count += await _insertTree(child, folderIdToGuidMap);
} catch (e) {
logger.e('Failed to import folder "$title": $e');
}
}
// Note: Separators are not supported by the Android API
}
return count;
}
/// Translate tree types from JSON format to internal format
Map<String, String> _translateTreeTypes(Map<String, dynamic> node) {
final folderIdToGuidMap = <String, String>{};
_normalizeNodeUrl(node);
final type = node['type'];
if (type == 'text/x-moz-place-container') {
node['type'] = BookmarkNodeType.folder.index;
final id = node['id']?.toString();
final guid = node['guid'] as String?;
if (id != null && guid != null) {
folderIdToGuidMap[id] = guid;
}
} else if (type == 'text/x-moz-place') {
node['type'] = BookmarkNodeType.item.index;
} else if (type == 'text/x-moz-place-separator') {
node['type'] = BookmarkNodeType.separator.index;
node.remove('title');
}
final children = node['children'] as List?;
if (children != null) {
for (final child in children) {
if (child is Map<String, dynamic>) {
folderIdToGuidMap.addAll(_translateTreeTypes(child));
}
}
}
return folderIdToGuidMap;
}
/// Fix up search queries with folder mappings
void _fixupSearchQueries(
Map<String, dynamic> node,
Map<String, String> folderIdToGuidMap,
) {
final url = _getNodeUrl(node);
if (url != null && url.startsWith('place:')) {
node['url'] = _fixupQuery(url, folderIdToGuidMap);
}
final children = node['children'] as List?;
if (children != null) {
for (final child in children) {
if (child is Map<String, dynamic>) {
_fixupSearchQueries(child, folderIdToGuidMap);
}
}
}
}
/// Replace folder IDs with GUIDs in place: URIs
String _fixupQuery(String queryURL, Map<String, String> folderIdToGuidMap) {
final regex = RegExp(r'folder=([A-Za-z0-9_]+)');
bool invalid = false;
final result = queryURL.replaceAllMapped(regex, (match) {
final folderId = match.group(1)!;
final guid = folderIdToGuidMap[folderId];
if (guid == null) {
invalid = true;
return 'invalidOldParentId=$folderId';
}
return 'parent=$guid';
});
if (invalid) {
return '$result&excludeItems=1';
}
return result;
}
/// Convert BookmarkNode to JSON (for export)
Map<String, dynamic>? _nodeToJson(BookmarkNode node, {bool isRoot = false}) {
// Skip invalid bookmarks
if (node.type == BookmarkNodeType.item) {
if (node.url == null || node.url!.isEmpty) {
logger.w('Skipping bookmark with invalid URL: ${node.guid}');
return null;
}
try {
Uri.parse(node.url!);
} catch (e) {
logger.w('Skipping bookmark with malformed URL: ${node.url}');
return null;
}
}
final json = <String, dynamic>{
'guid': node.guid,
'title': node.type == BookmarkNodeType.separator
? ''
: (node.title ?? ''),
'index': 0, // Will be set by parent
'dateAdded': node.dateAdded,
'lastModified': node.lastModified,
'typeCode': node.type.index + 1,
'type': _getTypeString(node.type),
};
if (isRoot &&
node.parentGuid != null &&
node.guid != BookmarkRoot.root.id) {
json['parentGuid'] = node.parentGuid;
}
final rootName = _getRootName(node.guid);
if (rootName != null) {
json['root'] = rootName;
}
if (node.type == BookmarkNodeType.item) {
json['url'] = node.url;
}
if (node.type == BookmarkNodeType.folder && node.children != null) {
final validChildren = <Map<String, dynamic>>[];
for (var i = 0; i < node.children!.length; i++) {
final childJson = _nodeToJson(node.children![i]);
if (childJson != null) {
childJson['index'] = validChildren.length;
validChildren.add(childJson);
}
}
if (validChildren.isNotEmpty) {
json['children'] = validChildren;
}
}
return json;
}
/// Convert BookmarkNodeType to Firefox type string
String _getTypeString(BookmarkNodeType type) {
switch (type) {
case BookmarkNodeType.item:
return 'text/x-moz-place';
case BookmarkNodeType.folder:
return 'text/x-moz-place-container';
case BookmarkNodeType.separator:
return 'text/x-moz-place-separator';
}
}
/// Get root folder name for JSON
String? _getRootName(String guid) {
if (guid == BookmarkRoot.root.id) return 'placesRoot';
if (guid == BookmarkRoot.menu.id) return 'bookmarksMenuFolder';
if (guid == BookmarkRoot.toolbar.id) return 'toolbarFolder';
if (guid == BookmarkRoot.unfiled.id) return 'unfiledBookmarksFolder';
if (guid == BookmarkRoot.mobile.id) return 'mobileFolder';
return null;
}
/// Get URL from node (accepts both 'url' and 'uri')
String? _getNodeUrl(Map<String, dynamic> node) {
return node['url'] as String? ?? node['uri'] as String?;
}
/// Normalize 'uri' to 'url' during import
void _normalizeNodeUrl(Map<String, dynamic> node) {
if (node.containsKey('uri')) {
node['url'] = node['uri'];
node.remove('uri');
}
}
/// Get node type from JSON
BookmarkNodeType _getNodeType(Map<String, dynamic> node) {
final type = node['type'];
if (type is int) {
return BookmarkNodeType.values[type];
}
if (type == 'text/x-moz-place-container') {
return BookmarkNodeType.folder;
} else if (type == 'text/x-moz-place') {
return BookmarkNodeType.item;
} else {
return BookmarkNodeType.separator;
}
}
}
@@ -0,0 +1,24 @@
/*
* 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/>.
*/
const fontSizeStep = 0.1;
const fontSizeMin = 0.5;
const fontSizeMax = 3.0;
const fontSizeDefault = 1.0;
@@ -0,0 +1,66 @@
/*
* 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 'package:flutter/material.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/site_permissions.dart';
/// Permission types with their display configuration
enum PermissionType {
camera(Icons.videocam, 'Camera'),
microphone(Icons.mic, 'Microphone'),
location(Icons.location_on, 'Location'),
notification(Icons.notifications, 'Notifications'),
persistentStorage(Icons.storage, 'Persistent Storage'),
crossOriginStorage(Icons.cookie, 'Cross-Origin Storage'),
mediaKeySystem(Icons.key, 'Media Key System (DRM)');
const PermissionType(this.icon, this.label);
final IconData icon;
final String label;
}
extension SitePermissionsGetter on SitePermissions? {
SitePermissionStatus? getStatus(PermissionType type) => switch (type) {
PermissionType.camera => this?.camera,
PermissionType.microphone => this?.microphone,
PermissionType.location => this?.location,
PermissionType.notification => this?.notification,
PermissionType.persistentStorage => this?.persistentStorage,
PermissionType.crossOriginStorage => this?.crossOriginStorageAccess,
PermissionType.mediaKeySystem => this?.mediaKeySystemAccess,
};
}
extension SitePermissionsUpdater on SitePermissionsWrapper {
SitePermissions withStatus(
PermissionType type,
SitePermissionStatus status,
) => switch (type) {
PermissionType.camera => copyWith.camera(status),
PermissionType.microphone => copyWith.microphone(status),
PermissionType.location => copyWith.location(status),
PermissionType.notification => copyWith.notification(status),
PermissionType.persistentStorage => copyWith.persistentStorage(status),
PermissionType.crossOriginStorage => copyWith.crossOriginStorageAccess(
status,
),
PermissionType.mediaKeySystem => copyWith.mediaKeySystemAccess(status),
};
}
@@ -0,0 +1,37 @@
/*
* 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 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
sealed class Sheet with FastEquatable {}
final class ViewTabsSheet extends Sheet {
@override
List<Object?> get hashParameters => [null];
}
final class SiteSettingsSheet extends Sheet {
final TabState tabState;
SiteSettingsSheet({required this.tabState});
@override
List<Object?> get hashParameters => [tabState];
}
@@ -0,0 +1,60 @@
/*
* 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 'package:copy_with_extension/copy_with_extension.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
part 'site_permissions.g.dart';
@CopyWith()
class SitePermissionsWrapper extends SitePermissions {
SitePermissionsWrapper({
required super.origin,
super.camera,
super.microphone,
super.location,
super.notification,
super.persistentStorage,
super.crossOriginStorageAccess,
super.mediaKeySystemAccess,
super.localDeviceAccess,
super.localNetworkAccess,
super.autoplayAudible,
super.autoplayInaudible,
required super.savedAt,
});
factory SitePermissionsWrapper.fromPermission(SitePermissions permissions) {
return SitePermissionsWrapper(
origin: permissions.origin,
camera: permissions.camera,
microphone: permissions.microphone,
location: permissions.location,
notification: permissions.notification,
persistentStorage: permissions.persistentStorage,
crossOriginStorageAccess: permissions.crossOriginStorageAccess,
mediaKeySystemAccess: permissions.mediaKeySystemAccess,
localDeviceAccess: permissions.localDeviceAccess,
localNetworkAccess: permissions.localNetworkAccess,
autoplayAudible: permissions.autoplayAudible,
autoplayInaudible: permissions.autoplayInaudible,
savedAt: permissions.savedAt,
);
}
}
@@ -0,0 +1,220 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'site_permissions.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$SitePermissionsWrapperCWProxy {
SitePermissionsWrapper origin(String origin);
SitePermissionsWrapper camera(SitePermissionStatus? camera);
SitePermissionsWrapper microphone(SitePermissionStatus? microphone);
SitePermissionsWrapper location(SitePermissionStatus? location);
SitePermissionsWrapper notification(SitePermissionStatus? notification);
SitePermissionsWrapper persistentStorage(
SitePermissionStatus? persistentStorage,
);
SitePermissionsWrapper crossOriginStorageAccess(
SitePermissionStatus? crossOriginStorageAccess,
);
SitePermissionsWrapper mediaKeySystemAccess(
SitePermissionStatus? mediaKeySystemAccess,
);
SitePermissionsWrapper localDeviceAccess(
SitePermissionStatus? localDeviceAccess,
);
SitePermissionsWrapper localNetworkAccess(
SitePermissionStatus? localNetworkAccess,
);
SitePermissionsWrapper autoplayAudible(AutoplayStatus? autoplayAudible);
SitePermissionsWrapper autoplayInaudible(AutoplayStatus? autoplayInaudible);
SitePermissionsWrapper savedAt(int savedAt);
/// 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 `SitePermissionsWrapper(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// SitePermissionsWrapper(...).copyWith(id: 12, name: "My name")
/// ```
SitePermissionsWrapper call({
String origin,
SitePermissionStatus? camera,
SitePermissionStatus? microphone,
SitePermissionStatus? location,
SitePermissionStatus? notification,
SitePermissionStatus? persistentStorage,
SitePermissionStatus? crossOriginStorageAccess,
SitePermissionStatus? mediaKeySystemAccess,
SitePermissionStatus? localDeviceAccess,
SitePermissionStatus? localNetworkAccess,
AutoplayStatus? autoplayAudible,
AutoplayStatus? autoplayInaudible,
int savedAt,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfSitePermissionsWrapper.copyWith(...)` or call `instanceOfSitePermissionsWrapper.copyWith.fieldName(value)` for a single field.
class _$SitePermissionsWrapperCWProxyImpl
implements _$SitePermissionsWrapperCWProxy {
const _$SitePermissionsWrapperCWProxyImpl(this._value);
final SitePermissionsWrapper _value;
@override
SitePermissionsWrapper origin(String origin) => call(origin: origin);
@override
SitePermissionsWrapper camera(SitePermissionStatus? camera) =>
call(camera: camera);
@override
SitePermissionsWrapper microphone(SitePermissionStatus? microphone) =>
call(microphone: microphone);
@override
SitePermissionsWrapper location(SitePermissionStatus? location) =>
call(location: location);
@override
SitePermissionsWrapper notification(SitePermissionStatus? notification) =>
call(notification: notification);
@override
SitePermissionsWrapper persistentStorage(
SitePermissionStatus? persistentStorage,
) => call(persistentStorage: persistentStorage);
@override
SitePermissionsWrapper crossOriginStorageAccess(
SitePermissionStatus? crossOriginStorageAccess,
) => call(crossOriginStorageAccess: crossOriginStorageAccess);
@override
SitePermissionsWrapper mediaKeySystemAccess(
SitePermissionStatus? mediaKeySystemAccess,
) => call(mediaKeySystemAccess: mediaKeySystemAccess);
@override
SitePermissionsWrapper localDeviceAccess(
SitePermissionStatus? localDeviceAccess,
) => call(localDeviceAccess: localDeviceAccess);
@override
SitePermissionsWrapper localNetworkAccess(
SitePermissionStatus? localNetworkAccess,
) => call(localNetworkAccess: localNetworkAccess);
@override
SitePermissionsWrapper autoplayAudible(AutoplayStatus? autoplayAudible) =>
call(autoplayAudible: autoplayAudible);
@override
SitePermissionsWrapper autoplayInaudible(AutoplayStatus? autoplayInaudible) =>
call(autoplayInaudible: autoplayInaudible);
@override
SitePermissionsWrapper savedAt(int savedAt) => call(savedAt: savedAt);
@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 `SitePermissionsWrapper(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// SitePermissionsWrapper(...).copyWith(id: 12, name: "My name")
/// ```
SitePermissionsWrapper call({
Object? origin = const $CopyWithPlaceholder(),
Object? camera = const $CopyWithPlaceholder(),
Object? microphone = const $CopyWithPlaceholder(),
Object? location = const $CopyWithPlaceholder(),
Object? notification = const $CopyWithPlaceholder(),
Object? persistentStorage = const $CopyWithPlaceholder(),
Object? crossOriginStorageAccess = const $CopyWithPlaceholder(),
Object? mediaKeySystemAccess = const $CopyWithPlaceholder(),
Object? localDeviceAccess = const $CopyWithPlaceholder(),
Object? localNetworkAccess = const $CopyWithPlaceholder(),
Object? autoplayAudible = const $CopyWithPlaceholder(),
Object? autoplayInaudible = const $CopyWithPlaceholder(),
Object? savedAt = const $CopyWithPlaceholder(),
}) {
return SitePermissionsWrapper(
origin: origin == const $CopyWithPlaceholder() || origin == null
? _value.origin
// ignore: cast_nullable_to_non_nullable
: origin as String,
camera: camera == const $CopyWithPlaceholder()
? _value.camera
// ignore: cast_nullable_to_non_nullable
: camera as SitePermissionStatus?,
microphone: microphone == const $CopyWithPlaceholder()
? _value.microphone
// ignore: cast_nullable_to_non_nullable
: microphone as SitePermissionStatus?,
location: location == const $CopyWithPlaceholder()
? _value.location
// ignore: cast_nullable_to_non_nullable
: location as SitePermissionStatus?,
notification: notification == const $CopyWithPlaceholder()
? _value.notification
// ignore: cast_nullable_to_non_nullable
: notification as SitePermissionStatus?,
persistentStorage: persistentStorage == const $CopyWithPlaceholder()
? _value.persistentStorage
// ignore: cast_nullable_to_non_nullable
: persistentStorage as SitePermissionStatus?,
crossOriginStorageAccess:
crossOriginStorageAccess == const $CopyWithPlaceholder()
? _value.crossOriginStorageAccess
// ignore: cast_nullable_to_non_nullable
: crossOriginStorageAccess as SitePermissionStatus?,
mediaKeySystemAccess: mediaKeySystemAccess == const $CopyWithPlaceholder()
? _value.mediaKeySystemAccess
// ignore: cast_nullable_to_non_nullable
: mediaKeySystemAccess as SitePermissionStatus?,
localDeviceAccess: localDeviceAccess == const $CopyWithPlaceholder()
? _value.localDeviceAccess
// ignore: cast_nullable_to_non_nullable
: localDeviceAccess as SitePermissionStatus?,
localNetworkAccess: localNetworkAccess == const $CopyWithPlaceholder()
? _value.localNetworkAccess
// ignore: cast_nullable_to_non_nullable
: localNetworkAccess as SitePermissionStatus?,
autoplayAudible: autoplayAudible == const $CopyWithPlaceholder()
? _value.autoplayAudible
// ignore: cast_nullable_to_non_nullable
: autoplayAudible as AutoplayStatus?,
autoplayInaudible: autoplayInaudible == const $CopyWithPlaceholder()
? _value.autoplayInaudible
// ignore: cast_nullable_to_non_nullable
: autoplayInaudible as AutoplayStatus?,
savedAt: savedAt == const $CopyWithPlaceholder() || savedAt == null
? _value.savedAt
// ignore: cast_nullable_to_non_nullable
: savedAt as int,
);
}
}
extension $SitePermissionsWrapperCopyWith on SitePermissionsWrapper {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfSitePermissionsWrapper.copyWith(...)` or `instanceOfSitePermissionsWrapper.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$SitePermissionsWrapperCWProxy get copyWith =>
_$SitePermissionsWrapperCWProxyImpl(this);
}
@@ -0,0 +1,143 @@
/*
* 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 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:weblibre/core/sort_field.dart';
import 'package:weblibre/data/database/converters/date_time_range.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
part 'tab_view_filter_options.g.dart';
enum TabTypeFilter {
all('All Tabs'),
regularOnly('Regular'),
privateOnly('Private'),
isolatedOnly('Isolated');
final String label;
const TabTypeFilter(this.label);
bool matches(TabMode? tabMode) => switch (this) {
all => true,
regularOnly => tabMode is RegularTabMode,
privateOnly => tabMode is PrivateTabMode,
isolatedOnly => tabMode is IsolatedTabMode,
};
}
enum TabSortType {
manual('Default', null),
titleAsc('Title A-Z', SortField.titleAsc),
titleDesc('Title Z-A', SortField.titleDesc),
urlAsc('URL A-Z', SortField.urlAsc),
urlDesc('URL Z-A', SortField.urlDesc),
newestFirst('Newest First', SortField.dateDesc),
oldestFirst('Oldest First', SortField.dateAsc);
final String label;
final SortField? sortField;
const TabSortType(this.label, this.sortField);
}
enum TabQuickInterval {
last1h('Last Hour', Duration(hours: 1)),
last3h('Last 3 Hours', Duration(hours: 3)),
last8h('Last 8 Hours', Duration(hours: 8)),
last1d('Last Day', Duration(days: 1)),
last3d('Last 3 Days', Duration(days: 3)),
last1w('Last Week', Duration(days: 7)),
last1m('Last Month', Duration(days: 30));
final String label;
final Duration duration;
const TabQuickInterval(this.label, this.duration);
DateTimeRange<DateTime> toDateRange() {
final now = DateTime.now();
return DateTimeRange(start: now.subtract(duration), end: now);
}
}
@JsonSerializable()
@CopyWith()
class TabViewFilterOptions with FastEquatable {
final TabTypeFilter tabTypeFilter;
final TabSortType sortType;
final bool sortPinnedFirst;
@DateTimeRangeConverter()
final DateTimeRange<DateTime>? dateRange;
final TabQuickInterval? quickInterval;
TabViewFilterOptions({
required this.tabTypeFilter,
required this.sortType,
required this.sortPinnedFirst,
required this.dateRange,
required this.quickInterval,
});
TabViewFilterOptions.withDefaults()
: this(
tabTypeFilter: TabTypeFilter.all,
sortType: TabSortType.manual,
sortPinnedFirst: true,
dateRange: null,
quickInterval: null,
);
bool get hasActiveFilter =>
tabTypeFilter != TabTypeFilter.all ||
sortType != TabSortType.manual ||
dateRange != null ||
quickInterval != null;
DateTimeRange<DateTime>? get effectiveDateRange =>
quickInterval?.toDateRange() ?? dateRange;
bool matchesTab(TabMode? tabMode, DateTime? timestamp) {
if (!tabTypeFilter.matches(tabMode)) return false;
final range = effectiveDateRange;
if (range != null &&
timestamp != null &&
(timestamp.isBefore(range.start) || timestamp.isAfter(range.end))) {
return false;
}
return true;
}
@override
List<Object?> get hashParameters => [
tabTypeFilter,
sortType,
sortPinnedFirst,
dateRange,
quickInterval,
];
factory TabViewFilterOptions.fromJson(Map<String, dynamic> json) =>
_$TabViewFilterOptionsFromJson(json);
Map<String, dynamic> toJson() => _$TabViewFilterOptionsToJson(this);
}
@@ -0,0 +1,169 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tab_view_filter_options.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$TabViewFilterOptionsCWProxy {
TabViewFilterOptions tabTypeFilter(TabTypeFilter tabTypeFilter);
TabViewFilterOptions sortType(TabSortType sortType);
TabViewFilterOptions sortPinnedFirst(bool sortPinnedFirst);
TabViewFilterOptions dateRange(DateTimeRange<DateTime>? dateRange);
TabViewFilterOptions quickInterval(TabQuickInterval? quickInterval);
/// 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 `TabViewFilterOptions(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// TabViewFilterOptions(...).copyWith(id: 12, name: "My name")
/// ```
TabViewFilterOptions call({
TabTypeFilter tabTypeFilter,
TabSortType sortType,
bool sortPinnedFirst,
DateTimeRange<DateTime>? dateRange,
TabQuickInterval? quickInterval,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfTabViewFilterOptions.copyWith(...)` or call `instanceOfTabViewFilterOptions.copyWith.fieldName(value)` for a single field.
class _$TabViewFilterOptionsCWProxyImpl
implements _$TabViewFilterOptionsCWProxy {
const _$TabViewFilterOptionsCWProxyImpl(this._value);
final TabViewFilterOptions _value;
@override
TabViewFilterOptions tabTypeFilter(TabTypeFilter tabTypeFilter) =>
call(tabTypeFilter: tabTypeFilter);
@override
TabViewFilterOptions sortType(TabSortType sortType) =>
call(sortType: sortType);
@override
TabViewFilterOptions sortPinnedFirst(bool sortPinnedFirst) =>
call(sortPinnedFirst: sortPinnedFirst);
@override
TabViewFilterOptions dateRange(DateTimeRange<DateTime>? dateRange) =>
call(dateRange: dateRange);
@override
TabViewFilterOptions quickInterval(TabQuickInterval? quickInterval) =>
call(quickInterval: quickInterval);
@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 `TabViewFilterOptions(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// TabViewFilterOptions(...).copyWith(id: 12, name: "My name")
/// ```
TabViewFilterOptions call({
Object? tabTypeFilter = const $CopyWithPlaceholder(),
Object? sortType = const $CopyWithPlaceholder(),
Object? sortPinnedFirst = const $CopyWithPlaceholder(),
Object? dateRange = const $CopyWithPlaceholder(),
Object? quickInterval = const $CopyWithPlaceholder(),
}) {
return TabViewFilterOptions(
tabTypeFilter:
tabTypeFilter == const $CopyWithPlaceholder() || tabTypeFilter == null
? _value.tabTypeFilter
// ignore: cast_nullable_to_non_nullable
: tabTypeFilter as TabTypeFilter,
sortType: sortType == const $CopyWithPlaceholder() || sortType == null
? _value.sortType
// ignore: cast_nullable_to_non_nullable
: sortType as TabSortType,
sortPinnedFirst:
sortPinnedFirst == const $CopyWithPlaceholder() ||
sortPinnedFirst == null
? _value.sortPinnedFirst
// ignore: cast_nullable_to_non_nullable
: sortPinnedFirst as bool,
dateRange: dateRange == const $CopyWithPlaceholder()
? _value.dateRange
// ignore: cast_nullable_to_non_nullable
: dateRange as DateTimeRange<DateTime>?,
quickInterval: quickInterval == const $CopyWithPlaceholder()
? _value.quickInterval
// ignore: cast_nullable_to_non_nullable
: quickInterval as TabQuickInterval?,
);
}
}
extension $TabViewFilterOptionsCopyWith on TabViewFilterOptions {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfTabViewFilterOptions.copyWith(...)` or `instanceOfTabViewFilterOptions.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$TabViewFilterOptionsCWProxy get copyWith =>
_$TabViewFilterOptionsCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TabViewFilterOptions _$TabViewFilterOptionsFromJson(
Map<String, dynamic> json,
) => TabViewFilterOptions(
tabTypeFilter: $enumDecode(_$TabTypeFilterEnumMap, json['tabTypeFilter']),
sortType: $enumDecode(_$TabSortTypeEnumMap, json['sortType']),
sortPinnedFirst: json['sortPinnedFirst'] as bool,
dateRange: const DateTimeRangeConverter().fromJson(
json['dateRange'] as Map<String, dynamic>?,
),
quickInterval: $enumDecodeNullable(
_$TabQuickIntervalEnumMap,
json['quickInterval'],
),
);
Map<String, dynamic> _$TabViewFilterOptionsToJson(
TabViewFilterOptions instance,
) => <String, dynamic>{
'tabTypeFilter': _$TabTypeFilterEnumMap[instance.tabTypeFilter]!,
'sortType': _$TabSortTypeEnumMap[instance.sortType]!,
'sortPinnedFirst': instance.sortPinnedFirst,
'dateRange': const DateTimeRangeConverter().toJson(instance.dateRange),
'quickInterval': _$TabQuickIntervalEnumMap[instance.quickInterval],
};
const _$TabTypeFilterEnumMap = {
TabTypeFilter.all: 'all',
TabTypeFilter.regularOnly: 'regularOnly',
TabTypeFilter.privateOnly: 'privateOnly',
TabTypeFilter.isolatedOnly: 'isolatedOnly',
};
const _$TabSortTypeEnumMap = {
TabSortType.manual: 'manual',
TabSortType.titleAsc: 'titleAsc',
TabSortType.titleDesc: 'titleDesc',
TabSortType.urlAsc: 'urlAsc',
TabSortType.urlDesc: 'urlDesc',
TabSortType.newestFirst: 'newestFirst',
TabSortType.oldestFirst: 'oldestFirst',
};
const _$TabQuickIntervalEnumMap = {
TabQuickInterval.last1h: 'last1h',
TabQuickInterval.last3h: 'last3h',
TabQuickInterval.last8h: 'last8h',
TabQuickInterval.last1d: 'last1d',
TabQuickInterval.last3d: 'last3d',
TabQuickInterval.last1w: 'last1w',
TabQuickInterval.last1m: 'last1m',
};
@@ -0,0 +1,687 @@
/*
* 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 'package:collection/collection.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/sort_field.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
import 'package:weblibre/features/geckoview/features/search/domain/entities/tab_preview.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.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';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'providers.g.dart';
typedef TabStateWithContainer = (TabState, ContainerData?);
@Riverpod()
bool canManualTabReorder(Ref ref) {
final filterOptions = ref.watch(tabViewFilterControllerProvider);
final hasActiveSearch = ref.watch(
tabSearchRepositoryProvider(
TabSearchPartition.preview,
).select((value) => (value.value?.query ?? '').isNotEmpty),
);
return !filterOptions.hasActiveFilter && !hasActiveSearch;
}
@Riverpod(keepAlive: true)
class SelectedBangTrigger extends _$SelectedBangTrigger {
// ignore: document_ignores api decision
// ignore: use_setters_to_change_properties
void setTrigger(BangKey trigger) {
state = trigger;
}
void clearTrigger() {
state = null;
}
@override
BangKey? build({String? domain}) {
return null;
}
}
@Riverpod()
class SelectedBangData extends _$SelectedBangData {
@override
BangData? build({String? domain}) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
final selectedBangTrigger = ref.watch(
selectedBangTriggerProvider(domain: domain),
);
final subscription = repository.watchBang(selectedBangTrigger).listen((
value,
) {
if (ref.mounted) {
state = value;
}
});
ref.onDispose(() async {
await subscription.cancel();
});
return null;
}
}
@Riverpod()
EquatableValue<List<DefaultTabEntity>> containerTabEntities(
Ref ref,
ContainerFilter containerFilter,
) {
final containerTabs = ref.watch(
watchContainerTabIdsProvider(
containerFilter,
).select((value) => value.value),
);
final tabList = ref.watch(tabListProvider);
final orderKeys = ref.watch(
watchTabOrderKeysProvider.select((value) => value.value),
);
final availableTabs =
containerTabs?.where((tabId) => tabList.value.contains(tabId)).toList() ??
[];
switch (containerFilter) {
case ContainerFilterById():
return EquatableValue(
availableTabs
.map(
(t) => DefaultTabEntity(
tabId: t,
orderKey: orderKeys?[t] ?? '',
containerId: containerFilter.containerId,
),
)
.toList(),
);
case ContainerFilterDisabled():
return ref.watch(
watchTabsContainerIdProvider(EquatableValue(availableTabs)).select(
(value) => EquatableValue(
value.value?.entries
.map(
(e) => DefaultTabEntity(
tabId: e.key,
orderKey: orderKeys?[e.key] ?? '',
containerId: e.value,
),
)
.toList() ??
[],
),
),
);
}
}
@Riverpod()
EquatableValue<Map<String, TabState>> containerTabStates(
Ref ref,
ContainerFilter containerFilter,
) {
final availableTabs = ref.watch(
containerTabEntitiesProvider(containerFilter),
);
final tabStates = ref.watch(tabStatesProvider);
return EquatableValue({
for (final tabEntity in availableTabs.value)
if (tabStates.containsKey(tabEntity.tabId))
tabEntity.tabId: tabStates[tabEntity.tabId]!,
});
}
@Riverpod(keepAlive: true)
EquatableValue<List<TabStateWithContainer>> fifoTabStates(Ref ref) {
final containerData = ref
.watch(watchContainersWithCountProvider.select((value) => value.value))
.mapNotNull(
(value) => Map.fromEntries(value.map((c) => MapEntry(c.id, c))),
);
final sortedTabs = ref.watch(
watchTabsFifoProvider.select((value) => value.value),
);
final tabStates = ref.watch(tabStatesProvider);
return EquatableValue([
if (sortedTabs != null)
for (final tab in sortedTabs)
if (tabStates.containsKey(tab.id))
(
tabStates[tab.id]!,
tab.containerId.mapNotNull(
(containerId) => containerData?[containerId],
),
),
]);
}
@Riverpod()
EquatableValue<List<TabStateWithContainer>>
selectedContainerTabStatesWithContainer(Ref ref) {
final filter = ref.watch(
selectedContainerProvider.select(
(value) => ContainerFilterById(containerId: value),
),
);
final containerData = ref
.watch(watchContainersWithCountProvider.select((value) => value.value))
.mapNotNull(
(value) => Map.fromEntries(value.map((c) => MapEntry(c.id, c))),
);
final sortedTabs = ref.watch(
containerTabEntitiesProvider(filter).select((value) => value.value),
);
final tabStates = ref.watch(tabStatesProvider);
final pinnedTabIds = ref.watch(
watchPinnedTabIdsProvider.select((value) => value.value),
);
final orderKeys = {for (final tab in sortedTabs) tab.tabId: tab.orderKey};
final items = [
for (final tabEntity in sortedTabs)
if (tabStates.containsKey(tabEntity.tabId))
(
tabStates[tabEntity.tabId]!,
tabEntity.containerId.mapNotNull(
(containerId) => containerData?[containerId],
),
),
];
items.sort((a, b) {
final aPinned = pinnedTabIds?.contains(a.$1.id) ?? false;
final bPinned = pinnedTabIds?.contains(b.$1.id) ?? false;
if (aPinned != bPinned) {
return aPinned ? -1 : 1;
}
final aOrderKey = orderKeys[a.$1.id] ?? '';
final bOrderKey = orderKeys[b.$1.id] ?? '';
return aOrderKey.compareTo(bOrderKey);
});
return EquatableValue(items);
}
@Riverpod()
EquatableValue<List<TabStateWithContainer>> quickTabSwitcherTabStates(
Ref ref,
QuickTabSwitcherMode mode,
) {
final effectiveMode = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
),
);
final selectedTabId = ref.watch(selectedTabProvider);
final tabStates = switch (effectiveMode) {
QuickTabSwitcherMode.lastUsedTabs => ref.watch(fifoTabStatesProvider).value,
QuickTabSwitcherMode.containerTabs =>
ref.watch(selectedContainerTabStatesWithContainerProvider).value,
};
return EquatableValue(switch (effectiveMode) {
QuickTabSwitcherMode.lastUsedTabs =>
tabStates.where((state) => state.$1.id != selectedTabId).toList(),
QuickTabSwitcherMode.containerTabs => tabStates,
});
}
@Riverpod()
Future<List<VisitInfo>> quickTabSwitcherHistorySuggestions(
Ref ref,
QuickTabSwitcherMode mode,
) async {
final effectiveMode = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
),
);
final showHistorySuggestions = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.quickTabSwitcherShowHistorySuggestions,
),
);
if (!showHistorySuggestions) {
return [];
}
final hasTabStates = ref.watch(
quickTabSwitcherTabStatesProvider(
effectiveMode,
).select((value) => value.value.isNotEmpty),
);
if (hasTabStates) {
return [];
}
return ref
.read(historyRepositoryProvider.notifier)
.getVisitsPaginated(count: 25);
}
@Riverpod()
AsyncValue<bool> quickTabSwitcherHasResults(
Ref ref,
QuickTabSwitcherMode mode,
) {
final effectiveMode = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
),
);
final showQuickTabSwitcherBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.tabBarShowQuickTabSwitcherBar,
),
);
if (!showQuickTabSwitcherBar) {
return const AsyncValue.data(false);
}
final hasResults = ref.watch(
quickTabSwitcherTabStatesProvider(
effectiveMode,
).select((value) => value.value.isNotEmpty),
);
if (hasResults) {
return const AsyncValue.data(true);
}
return ref
.watch(quickTabSwitcherHistorySuggestionsProvider(effectiveMode))
.whenData((visits) => visits.isNotEmpty);
}
@Riverpod()
EquatableValue<List<TabEntity>> suggestedTabEntities(
Ref ref,
String? containerId,
) {
final enableAiFeatures = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.enableLocalAiFeatures,
),
);
if (!enableAiFeatures) {
return EquatableValue([]);
}
final excludedTabIds = ref.watch(
watchContainerTabIdsProvider(
// ignore: provider_parameters
ContainerFilterById(containerId: containerId),
).select((value) => EquatableValue(value.value)),
);
final orderKeys = ref.watch(
watchTabOrderKeysProvider.select((value) => value.value),
);
final suggestions = ref.watch(
containerTabSuggestionsProvider(containerId).select(
(value) => EquatableValue(
value.value.mapNotNull(
(result) => result
.whereNot(
(tabId) => excludedTabIds.value?.contains(tabId) ?? false,
)
.map(
(tabId) => DefaultTabEntity(
tabId: tabId,
orderKey: orderKeys?[tabId] ?? '',
containerId: containerId,
),
)
.toList(),
) ??
const [],
),
),
);
return suggestions;
}
List<TabEntity> _applyTabFiltersAndSort(
List<TabEntity> entities,
TabViewFilterOptions filterOptions,
Map<String, TabState> tabStates,
Set<String> pinnedTabIds,
Map<String, DateTime>? tabTimestamps,
) {
final sortField = filterOptions.sortType.sortField;
final filteredRows = <_TabFilterRow>[];
var hasPinned = false;
for (final entity in entities) {
final tabState = tabStates[entity.tabId];
final timestamp = tabTimestamps?[entity.tabId];
if (!filterOptions.matchesTab(tabState?.tabMode, timestamp)) {
continue;
}
final isPinned = pinnedTabIds.contains(entity.tabId);
hasPinned = hasPinned || isPinned;
filteredRows.add(
_TabFilterRow(
entity: entity,
isPinned: isPinned,
titleKey:
sortField == SortField.titleAsc || sortField == SortField.titleDesc
? (tabState?.titleOrAuthority ?? '').toLowerCase()
: null,
urlKey: sortField == SortField.urlAsc || sortField == SortField.urlDesc
? (tabState?.url.toString() ?? '')
: null,
dateKey:
sortField == SortField.dateAsc || sortField == SortField.dateDesc
? (timestamp ?? DateTime(0))
: null,
),
);
}
if (sortField != null) {
filteredRows.sort((a, b) {
if (filterOptions.sortPinnedFirst && a.isPinned != b.isPinned) {
return b.isPinned ? 1 : -1;
}
final cmp = switch (sortField) {
SortField.titleAsc => a.titleKey!.compareTo(b.titleKey!),
SortField.titleDesc => b.titleKey!.compareTo(a.titleKey!),
SortField.urlAsc => a.urlKey!.compareTo(b.urlKey!),
SortField.urlDesc => b.urlKey!.compareTo(a.urlKey!),
SortField.dateAsc => a.dateKey!.compareTo(b.dateKey!),
SortField.dateDesc => b.dateKey!.compareTo(a.dateKey!),
};
if (cmp == 0) return a.entity.orderKey.compareTo(b.entity.orderKey);
return cmp;
});
return filteredRows.map((row) => row.entity).toList();
}
if (!hasPinned || !filterOptions.sortPinnedFirst) {
return filteredRows.map((row) => row.entity).toList();
}
final pinned = <TabEntity>[];
final unpinned = <TabEntity>[];
for (final row in filteredRows) {
if (row.isPinned) {
pinned.add(row.entity);
} else {
unpinned.add(row.entity);
}
}
return [...pinned, ...unpinned];
}
class _TabFilterRow {
final TabEntity entity;
final bool isPinned;
final String? titleKey;
final String? urlKey;
final DateTime? dateKey;
const _TabFilterRow({
required this.entity,
required this.isPinned,
required this.titleKey,
required this.urlKey,
required this.dateKey,
});
}
@Riverpod()
EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
Ref ref, {
required TabSearchPartition searchPartition,
required ContainerFilter containerFilter,
required bool groupTrees,
}) {
final orderKeys = ref.watch(
watchTabOrderKeysProvider.select((value) => value.value),
);
final tabSearchResults = ref
.watch(
tabSearchRepositoryProvider(searchPartition).select(
(value) => EquatableValue(
value.value.mapNotNull(
(result) => result.results
.map(
(tab) => SearchResultTabEntity(
tabId: tab.id,
orderKey: orderKeys?[tab.id] ?? '',
containerId: tab.containerId,
searchQuery: result.query,
),
)
.toList(),
),
),
),
)
.value;
final availableTabs = ref.watch(
containerTabEntitiesProvider(containerFilter),
);
// Tree mode: no filtering/sorting, return as-is
if (groupTrees && tabSearchResults == null) {
final trees = ref.watch(
watchTabTreesProvider.select(
(value) => EquatableValue(
value.value?.map((tree) {
// Find the container ID for the latest tab
final containerForTab = availableTabs.value
.where((t) => t.tabId == tree.latestTabId)
.firstOrNull
?.containerId;
return TabTreeEntity(
tabId: tree.latestTabId,
orderKey: orderKeys?[tree.latestTabId] ?? '',
containerId: containerForTab,
rootId: tree.rootTabId,
totalTabs: tree.totalTabs,
);
}).toList() ??
[],
),
),
);
return EquatableValue(
trees.value
.where(
(tree) => availableTabs.value.any(
(available) => available.tabId == tree.tabId,
),
)
.toList(),
);
}
final tabStates = ref.watch(tabStatesProvider);
final filterOptions = ref.watch(tabViewFilterControllerProvider);
final pinnedTabIds = ref.watch(
watchPinnedTabIdsProvider.select(
(value) => value.value ?? const <String>{},
),
);
// Only pull timestamps from DB when date filtering/sorting is active
final needsTimestamps =
filterOptions.effectiveDateRange != null ||
filterOptions.sortType.sortField == SortField.dateAsc ||
filterOptions.sortType.sortField == SortField.dateDesc;
final tabTimestamps = needsTimestamps
? ref.watch(watchTabTimestampsProvider.select((value) => value.value))
: null;
if (tabSearchResults == null) {
if (filterOptions.hasActiveFilter || pinnedTabIds.isNotEmpty) {
return EquatableValue(
_applyTabFiltersAndSort(
availableTabs.value,
filterOptions,
tabStates,
pinnedTabIds,
tabTimestamps,
),
);
}
return availableTabs;
}
final searchFiltered = tabSearchResults
.where(
(tab) => availableTabs.value.any(
(available) => available.tabId == tab.tabId,
),
)
.toList();
if (filterOptions.hasActiveFilter || pinnedTabIds.isNotEmpty) {
return EquatableValue(
_applyTabFiltersAndSort(
searchFiltered,
filterOptions,
tabStates,
pinnedTabIds,
tabTimestamps,
),
);
}
return EquatableValue(searchFiltered);
}
@Riverpod()
EquatableValue<List<TabPreview>> filteredTabPreviews(
Ref ref,
TabSearchPartition searchPartition,
ContainerFilter containerFilter,
) {
final tabSearchResults = ref
.watch(
tabSearchRepositoryProvider(
searchPartition,
).select((value) => EquatableValue(value.value)),
)
.value;
final availableTabStates = ref.watch(
containerTabStatesProvider(containerFilter),
);
if (tabSearchResults == null) {
return EquatableValue([]);
}
return EquatableValue(
tabSearchResults.results
.where((tab) => availableTabStates.value.containsKey(tab.id))
.map((tab) {
final tabState = availableTabStates.value[tab.id]!;
return TabPreview(
id: tab.id,
containerId: tab.containerId,
title: tab.title ?? tabState.title,
icon: tabState.icon,
url: tab.cleanUrl ?? tabState.url,
highlightedUrl: tab.url,
extractedContent: tab.extractedContent,
fullContent: tab.fullContent,
sourceSearchQuery: tabSearchResults.query,
);
})
.whereType<TabPreview>()
.toList(),
);
}
@Riverpod()
class AppLinksModeNotifier extends _$AppLinksModeNotifier {
final _service = GeckoEngineSettingsService();
Future<void> setMode(AppLinksMode mode) async {
await _service.setAppLinksMode(mode);
ref.invalidateSelf();
}
@override
Future<AppLinksMode> build() {
return _service.getAppLinksMode();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
/*
* 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:async';
import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rxdart/rxdart.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/data/models/received_intent_parameter.dart';
import 'package:weblibre/features/app_widget/domain/services/home_widget.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
import 'package:weblibre/features/share_intent/domain/services/sharing_intent.dart';
part 'intent.g.dart';
final _contentParserTransformer =
StreamTransformer<ReceivedIntentParameter, SharedContent>.fromHandlers(
handleData: (parameter, sink) {
final parsed = parameter.content.mapNotNull(
(content) => SharedContent.parse(
content,
contextId: parameter.contextId,
),
);
if (parsed != null) {
sink.add(parsed);
} else if (parameter.tool == 'search') {
sink.add(SharedText(SearchRoute.emptySearchText));
}
},
);
@Riverpod()
class EngineBoundIntentStream extends _$EngineBoundIntentStream {
@override
Stream<SharedContent> build() {
final engineReady = ref.watch(engineReadyStateProvider);
if (!engineReady) {
return const Stream.empty();
}
final sharingItentStream = ref.watch(sharingIntentStreamProvider);
final appWidgetLaunchStream = ref.watch(appWidgetLaunchStreamProvider);
// Create a broadcast stream controller to buffer events
final controller = StreamController<SharedContent>.broadcast();
final subscription =
MergeStream([
sharingItentStream.transform(_contentParserTransformer),
appWidgetLaunchStream.transform(_contentParserTransformer),
]).listen(
controller.add,
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Intent stream error',
error: error,
stackTrace: stackTrace,
);
controller.addError(error, stackTrace);
},
onDone: controller.close,
);
ref.onDispose(() async {
await subscription.cancel();
await controller.close();
});
return controller.stream;
}
@override
bool updateShouldNotify(
AsyncValue<SharedContent> previous,
AsyncValue<SharedContent> next,
) {
// Always notify if e.g. same link opened consecutive that are elseiwese filtered on == comaprison
return true;
}
}
@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'intent.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(EngineBoundIntentStream)
final engineBoundIntentStreamProvider = EngineBoundIntentStreamProvider._();
final class EngineBoundIntentStreamProvider
extends $StreamNotifierProvider<EngineBoundIntentStream, SharedContent> {
EngineBoundIntentStreamProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'engineBoundIntentStreamProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$engineBoundIntentStreamHash();
@$internal
@override
EngineBoundIntentStream create() => EngineBoundIntentStream();
}
String _$engineBoundIntentStreamHash() =>
r'618eb6431da93989de6f1780cba2993458b0014e';
abstract class _$EngineBoundIntentStream
extends $StreamNotifier<SharedContent> {
Stream<SharedContent> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<SharedContent>, SharedContent>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<SharedContent>, SharedContent>,
AsyncValue<SharedContent>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,37 @@
/*
* 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:ui';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'lifecycle.g.dart';
@Riverpod(keepAlive: true)
class BrowserViewLifecycle extends _$BrowserViewLifecycle {
// ignore: use_setters_to_change_properties
void update(AppLifecycleState? newState) {
state = newState;
}
@override
AppLifecycleState? build() {
return null;
}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'lifecycle.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BrowserViewLifecycle)
final browserViewLifecycleProvider = BrowserViewLifecycleProvider._();
final class BrowserViewLifecycleProvider
extends $NotifierProvider<BrowserViewLifecycle, AppLifecycleState?> {
BrowserViewLifecycleProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'browserViewLifecycleProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$browserViewLifecycleHash();
@$internal
@override
BrowserViewLifecycle create() => BrowserViewLifecycle();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AppLifecycleState? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AppLifecycleState?>(value),
);
}
}
String _$browserViewLifecycleHash() =>
r'9082d058611aaaae268b60b8ce130150f73d453d';
abstract class _$BrowserViewLifecycle extends $Notifier<AppLifecycleState?> {
AppLifecycleState? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AppLifecycleState?, AppLifecycleState?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AppLifecycleState?, AppLifecycleState?>,
AppLifecycleState?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,74 @@
/*
* 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 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/site_permissions.dart';
part 'site_permissions.g.dart';
typedef PermissionUpdater = SitePermissions Function(SitePermissionsWrapper);
@Riverpod(keepAlive: true)
class SitePermissionsRepository extends _$SitePermissionsRepository {
final _api = GeckoSitePermissionsApi();
Future<SitePermissions?> getPermissions() async {
final permissions = await _api.getSitePermissions(origin, isPrivate);
state = AsyncValue.data(permissions);
return permissions;
}
Future<void> updatePermission(PermissionUpdater updater) async {
final currentPermissions =
await getPermissions() ??
SitePermissions(
origin: origin,
savedAt: DateTime.now().millisecondsSinceEpoch,
);
await setPermissions(
updater(SitePermissionsWrapper.fromPermission(currentPermissions)),
);
}
Future<void> setPermissions(SitePermissions permissions) async {
if (permissions.origin != origin) {
throw Exception('Origin does not match');
}
await _api.setSitePermissions(permissions, isPrivate);
ref.invalidateSelf();
}
Future<void> deletePermissions() async {
await _api.deleteSitePermissions(origin, isPrivate);
ref.invalidateSelf();
}
@override
Future<SitePermissions?> build({
required String origin,
required bool isPrivate,
}) async {
final permissions = await _api.getSitePermissions(origin, isPrivate);
return permissions;
}
}
@@ -0,0 +1,116 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'site_permissions.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(SitePermissionsRepository)
final sitePermissionsRepositoryProvider = SitePermissionsRepositoryFamily._();
final class SitePermissionsRepositoryProvider
extends
$AsyncNotifierProvider<SitePermissionsRepository, SitePermissions?> {
SitePermissionsRepositoryProvider._({
required SitePermissionsRepositoryFamily super.from,
required ({String origin, bool isPrivate}) super.argument,
}) : super(
retry: null,
name: r'sitePermissionsRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sitePermissionsRepositoryHash();
@override
String toString() {
return r'sitePermissionsRepositoryProvider'
''
'$argument';
}
@$internal
@override
SitePermissionsRepository create() => SitePermissionsRepository();
@override
bool operator ==(Object other) {
return other is SitePermissionsRepositoryProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$sitePermissionsRepositoryHash() =>
r'b821fbd3a457f5b9cfc6c9d398c93f09c6b30b0b';
final class SitePermissionsRepositoryFamily extends $Family
with
$ClassFamilyOverride<
SitePermissionsRepository,
AsyncValue<SitePermissions?>,
SitePermissions?,
FutureOr<SitePermissions?>,
({String origin, bool isPrivate})
> {
SitePermissionsRepositoryFamily._()
: super(
retry: null,
name: r'sitePermissionsRepositoryProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: false,
);
SitePermissionsRepositoryProvider call({
required String origin,
required bool isPrivate,
}) => SitePermissionsRepositoryProvider._(
argument: (origin: origin, isPrivate: isPrivate),
from: this,
);
@override
String toString() => r'sitePermissionsRepositoryProvider';
}
abstract class _$SitePermissionsRepository
extends $AsyncNotifier<SitePermissions?> {
late final _$args = ref.$arg as ({String origin, bool isPrivate});
String get origin => _$args.origin;
bool get isPrivate => _$args.isPrivate;
FutureOr<SitePermissions?> build({
required String origin,
required bool isPrivate,
});
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<AsyncValue<SitePermissions?>, SitePermissions?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<SitePermissions?>, SitePermissions?>,
AsyncValue<SitePermissions?>,
Object?,
Object?
>;
element.handleCreate(
ref,
() => build(origin: _$args.origin, isPrivate: _$args.isPrivate),
);
}
}
@@ -0,0 +1,77 @@
/*
* 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 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'tracking_protection.g.dart';
/// Repository for managing per-site Enhanced Tracking Protection exceptions
///
/// Wraps the GeckoTrackingProtectionApi and handles state invalidation
/// automatically after mutations.
@Riverpod(keepAlive: true)
class TrackingProtectionRepository extends _$TrackingProtectionRepository {
final _api = GeckoTrackingProtectionApi();
/// Check if a tab has a tracking protection exception
///
/// Returns true if ETP is disabled for this site
Future<bool> containsException(String tabId) {
return _api.containsException(tabId);
}
/// Add tracking protection exception for a tab (disable ETP for this site)
Future<void> addException(String tabId) async {
await _api.addException(tabId);
await _invalidateWithDelay();
}
/// Remove tracking protection exception for a tab (enable ETP for this site)
Future<void> removeException(String tabId) async {
await _api.removeException(tabId);
await _invalidateWithDelay();
}
/// Remove a specific exception by URL and refresh the exceptions list
Future<void> removeExceptionByUrl(String url) async {
await _api.removeExceptionByUrl(url);
await _invalidateWithDelay();
}
/// Remove all tracking protection exceptions
Future<void> removeAllExceptions() async {
await _api.removeAllExceptions();
await _invalidateWithDelay();
}
/// Helper method to invalidate repository state after mutations with delay
Future<void> _invalidateWithDelay() async {
await Future.delayed(const Duration(milliseconds: 100)).whenComplete(() {
if (ref.mounted) {
ref.invalidateSelf();
}
});
}
@override
Future<List<TrackingProtectionException>> build() {
return _api.fetchExceptions();
}
}
@@ -0,0 +1,86 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tracking_protection.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Repository for managing per-site Enhanced Tracking Protection exceptions
///
/// Wraps the GeckoTrackingProtectionApi and handles state invalidation
/// automatically after mutations.
@ProviderFor(TrackingProtectionRepository)
final trackingProtectionRepositoryProvider =
TrackingProtectionRepositoryProvider._();
/// Repository for managing per-site Enhanced Tracking Protection exceptions
///
/// Wraps the GeckoTrackingProtectionApi and handles state invalidation
/// automatically after mutations.
final class TrackingProtectionRepositoryProvider
extends
$AsyncNotifierProvider<
TrackingProtectionRepository,
List<TrackingProtectionException>
> {
/// Repository for managing per-site Enhanced Tracking Protection exceptions
///
/// Wraps the GeckoTrackingProtectionApi and handles state invalidation
/// automatically after mutations.
TrackingProtectionRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'trackingProtectionRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$trackingProtectionRepositoryHash();
@$internal
@override
TrackingProtectionRepository create() => TrackingProtectionRepository();
}
String _$trackingProtectionRepositoryHash() =>
r'f4b09ed513da6bd5a9f4d01833a2dbba990f59de';
/// Repository for managing per-site Enhanced Tracking Protection exceptions
///
/// Wraps the GeckoTrackingProtectionApi and handles state invalidation
/// automatically after mutations.
abstract class _$TrackingProtectionRepository
extends $AsyncNotifier<List<TrackingProtectionException>> {
FutureOr<List<TrackingProtectionException>> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
AsyncValue<List<TrackingProtectionException>>,
List<TrackingProtectionException>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<List<TrackingProtectionException>>,
List<TrackingProtectionException>
>,
AsyncValue<List<TrackingProtectionException>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,136 @@
/*
* 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:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
part 'browser_addon.g.dart';
const _signatureRequiredPref = 'xpinstall.signatures.required';
@Riverpod(keepAlive: true)
class AllowUnsignedExtensions extends _$AllowUnsignedExtensions {
Future<void> setAllowUnsigned({required bool allow}) async {
final fixator = ref.read(preferenceFixatorProvider.notifier);
if (allow) {
await fixator.register(_signatureRequiredPref, false);
} else {
await fixator.unregister(_signatureRequiredPref);
await GeckoPrefService().applyPrefs({_signatureRequiredPref: true});
}
state = AsyncData(allow);
}
@override
FutureOr<bool> build() async {
final prefs =
await GeckoPrefService().getPrefs([_signatureRequiredPref]);
final pref = prefs[_signatureRequiredPref];
final allowUnsigned = pref?.value == false;
// Re-register with fixator to prevent Gecko from resetting
if (allowUnsigned) {
await ref
.read(preferenceFixatorProvider.notifier)
.register(_signatureRequiredPref, false);
}
return allowUnsigned;
}
}
@Riverpod(keepAlive: true)
class BrowserAddonService extends _$BrowserAddonService {
Future<Uri> getAddonXpiUrl(String guid) async {
final url = 'https://addons.mozilla.org/api/v5/addons/addon/$guid/';
try {
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
// ignore: avoid_dynamic_calls
final xpiUrl = data['current_version']['file']['url'] as String;
return Uri.parse(xpiUrl);
} else {
throw Exception('Failed to load addon data: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error fetching addon data: $e');
}
}
Future<bool> install(String addonGuid) async {
try {
final xpiUrl = await getAddonXpiUrl(addonGuid);
if (!ref.mounted) return false;
await ref.read(addonServiceProvider).installAddon(xpiUrl);
return true;
} catch (e, s) {
logger.e('Failed installing $addonGuid', error: e, stackTrace: s);
return false;
}
}
Future<bool> installFromFile(String filePath) async {
try {
// Validate file exists and has .xpi extension
final file = File(filePath);
if (!file.existsSync()) {
throw Exception('File does not exist: $filePath');
}
final extension = filePath.toLowerCase();
if (!extension.endsWith('.xpi')) {
throw Exception('Invalid file type. Expected .xpi file');
}
// Create file:// URI and install
final fileUri = Uri.file(filePath);
if (!ref.mounted) return false;
await ref.read(addonServiceProvider).installAddon(fileUri);
return true;
} catch (e, s) {
logger.e(
'Failed installing from file: $filePath',
error: e,
stackTrace: s,
);
rethrow;
}
}
@override
void build() {}
}
@@ -0,0 +1,108 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'browser_addon.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(AllowUnsignedExtensions)
final allowUnsignedExtensionsProvider = AllowUnsignedExtensionsProvider._();
final class AllowUnsignedExtensionsProvider
extends $AsyncNotifierProvider<AllowUnsignedExtensions, bool> {
AllowUnsignedExtensionsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'allowUnsignedExtensionsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$allowUnsignedExtensionsHash();
@$internal
@override
AllowUnsignedExtensions create() => AllowUnsignedExtensions();
}
String _$allowUnsignedExtensionsHash() =>
r'3b67f51cabf8e2f37d992320c5b93bb61c6dbe1f';
abstract class _$AllowUnsignedExtensions extends $AsyncNotifier<bool> {
FutureOr<bool> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<bool>, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<bool>, bool>,
AsyncValue<bool>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(BrowserAddonService)
final browserAddonServiceProvider = BrowserAddonServiceProvider._();
final class BrowserAddonServiceProvider
extends $NotifierProvider<BrowserAddonService, void> {
BrowserAddonServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'browserAddonServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$browserAddonServiceHash();
@$internal
@override
BrowserAddonService create() => BrowserAddonService();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$browserAddonServiceHash() =>
r'b90e69ce77b846d6056c800f232a8aa50b4b21d0';
abstract class _$BrowserAddonService extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,76 @@
/*
* 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 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
part 'browser_data.g.dart';
@Riverpod(keepAlive: true)
class BrowserDataService extends _$BrowserDataService {
final _service = GeckoDeleteBrowserDataService();
var _onStartDeleted = false;
Future<void> deleteDataOnEngineStart(
Set<DeleteBrowsingDataType>? types,
) async {
if (!_onStartDeleted) {
_onStartDeleted = true;
return deleteData(types);
}
}
Future<void> deleteData(Set<DeleteBrowsingDataType>? types) async {
if (types != null) {
for (final type in types) {
switch (type) {
case DeleteBrowsingDataType.tabs:
await _service.deleteTabs();
case DeleteBrowsingDataType.history:
await _service.deleteBrowsingHistory();
case DeleteBrowsingDataType.cookies:
await _service.deleteCookiesAndSiteData();
case DeleteBrowsingDataType.cache:
await _service.deleteCachedFiles();
case DeleteBrowsingDataType.permissions:
await _service.deleteSitePermissions();
case DeleteBrowsingDataType.downloads:
await _service.deleteDownloads();
}
}
}
}
Future<void> clearDataForContext(String contextId) {
return _service.clearDataForContext(contextId);
}
Future<void> clearContainerDataOnEngineStart(List<String> contextIds) async {
if (!_onStartDeleted && contextIds.isNotEmpty) {
for (final contextId in contextIds) {
await clearDataForContext(contextId);
}
}
}
@override
void build() {}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'browser_data.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BrowserDataService)
final browserDataServiceProvider = BrowserDataServiceProvider._();
final class BrowserDataServiceProvider
extends $NotifierProvider<BrowserDataService, void> {
BrowserDataServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'browserDataServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$browserDataServiceHash();
@$internal
@override
BrowserDataService create() => BrowserDataService();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$browserDataServiceHash() =>
r'861943be10c4dea325484b6a28ba21f3ff0d29fa';
abstract class _$BrowserDataService extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,306 @@
/*
* 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 'package:collection/collection.dart';
import 'package:flutter/material.dart' show ThemeMode;
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'engine_settings_replication.g.dart';
/// Checks if any Custom ETP setting changed between two EngineSettings instances.
bool _customEtpSettingsChanged(
GeckoEngineSettings? previous,
GeckoEngineSettings current,
) {
if (previous == null) return true;
return previous.blockCookies != current.blockCookies ||
previous.customCookiePolicy != current.customCookiePolicy ||
previous.blockTrackingContent != current.blockTrackingContent ||
previous.trackingContentScope != current.trackingContentScope ||
previous.blockCryptominers != current.blockCryptominers ||
previous.blockFingerprinters != current.blockFingerprinters ||
previous.blockRedirectTrackers != current.blockRedirectTrackers ||
previous.blockSuspectedFingerprinters !=
current.blockSuspectedFingerprinters ||
previous.suspectedFingerprintersScope !=
current.suspectedFingerprintersScope ||
previous.allowListBaseline != current.allowListBaseline ||
previous.allowListConvenience != current.allowListConvenience;
}
@Riverpod(keepAlive: true)
class EngineSettingsReplicationService
extends _$EngineSettingsReplicationService {
final _service = GeckoEngineSettingsService();
@override
void build() {
var initialSettingsSent = false;
ref.listen(
fireImmediately: true,
generalSettingsWithDefaultsProvider.select(
(settings) => settings.themeMode,
),
(previous, next) async {
final theme = switch (next) {
ThemeMode.system => ColorScheme.system,
ThemeMode.light => ColorScheme.light,
ThemeMode.dark => ColorScheme.dark,
};
await _service.preferredColorScheme(theme);
},
onError: (error, stackTrace) {
logger.e(
'Error listening to generalSettingsRepositoryProvider',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listen(
fireImmediately: true,
generalSettingsWithDefaultsProvider.select(
(settings) => settings.pullToRefreshEnabled,
),
(previous, next) async {
await _service.setPullToRefreshEnabled(next);
},
onError: (error, stackTrace) {
logger.e(
'Error listening to pullToRefreshEnabled',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listen(
fireImmediately: true,
generalSettingsWithDefaultsProvider.select(
(settings) => settings.useExternalDownloadManager,
),
(previous, next) async {
await _service.setUseExternalDownloadManager(next);
},
onError: (error, stackTrace) {
logger.e(
'Error listening to useExternalDownloadManager',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listen(
fireImmediately: true,
engineSettingsRepositoryProvider,
(previous, next) async {
final settings = next.value;
if (settings != null) {
if (initialSettingsSent && previous != null) {
if (previous.value?.javascriptEnabled !=
settings.javascriptEnabled) {
await _service.javascriptEnabled(settings.javascriptEnabled);
}
// Check if tracking protection policy mode changed OR any custom ETP setting changed
final policyModeChanged =
previous.value?.trackingProtectionPolicy !=
settings.trackingProtectionPolicy;
final customSettingsChanged =
settings.trackingProtectionPolicy ==
TrackingProtectionPolicy.custom &&
_customEtpSettingsChanged(previous.value, settings);
if (policyModeChanged || customSettingsChanged) {
// Always send full custom settings when in CUSTOM mode
if (settings.trackingProtectionPolicy ==
TrackingProtectionPolicy.custom) {
await _service.customTrackingProtectionPolicy(
trackingProtectionPolicy: settings.trackingProtectionPolicy,
contentBlocking: settings.contentBlocking,
blockCookies: settings.blockCookies,
customCookiePolicy: settings.customCookiePolicy,
blockTrackingContent: settings.blockTrackingContent,
trackingContentScope: settings.trackingContentScope,
blockCryptominers: settings.blockCryptominers,
blockFingerprinters: settings.blockFingerprinters,
blockRedirectTrackers: settings.blockRedirectTrackers,
blockSuspectedFingerprinters:
settings.blockSuspectedFingerprinters,
suspectedFingerprintersScope:
settings.suspectedFingerprintersScope,
allowListBaseline: settings.allowListBaseline,
allowListConvenience: settings.allowListConvenience,
);
} else {
await _service.trackingProtectionPolicy(
settings.trackingProtectionPolicy,
contentBlocking: settings.contentBlocking,
);
}
}
if (previous.value?.httpsOnlyMode != settings.httpsOnlyMode) {
await _service.httpsOnlyMode(settings.httpsOnlyMode);
}
if (previous.value?.globalPrivacyControlEnabled !=
settings.globalPrivacyControlEnabled) {
await _service.globalPrivacyControlEnabled(
settings.globalPrivacyControlEnabled,
);
}
if (previous.value?.preferredColorScheme !=
settings.preferredColorScheme) {
await _service.preferredColorScheme(
settings.preferredColorScheme,
);
}
if (previous.value?.cookieBannerHandlingMode !=
settings.cookieBannerHandlingMode) {
await _service.cookieBannerHandlingMode(
settings.cookieBannerHandlingMode,
);
}
if (previous.value?.cookieBannerHandlingModePrivateBrowsing !=
settings.cookieBannerHandlingModePrivateBrowsing) {
await _service.cookieBannerHandlingModePrivateBrowsing(
settings.cookieBannerHandlingModePrivateBrowsing,
);
}
if (previous.value?.cookieBannerHandlingGlobalRules !=
settings.cookieBannerHandlingGlobalRules) {
await _service.cookieBannerHandlingGlobalRules(
settings.cookieBannerHandlingGlobalRules,
);
}
if (previous.value?.cookieBannerHandlingGlobalRulesSubFrames !=
settings.cookieBannerHandlingGlobalRulesSubFrames) {
await _service.cookieBannerHandlingGlobalRulesSubFrames(
settings.cookieBannerHandlingGlobalRulesSubFrames,
);
}
if (previous.value?.webContentIsolationStrategy !=
settings.webContentIsolationStrategy) {
await _service.webContentIsolationStrategy(
settings.webContentIsolationStrategy,
);
}
if (previous.value?.contentBlocking != settings.contentBlocking) {
await _service.contentBlocking(settings.contentBlocking);
}
if (previous.value?.dohSettings != settings.dohSettings) {
await _service.dohSettings(settings.dohSettings);
}
if (previous.value?.fingerprintingProtectionOverrides !=
settings.fingerprintingProtectionOverrides) {
await _service.fingerprintingProtectionOverrides(
settings.fingerprintingProtectionOverrides,
);
}
if (previous.value?.enablePdfJs != settings.enablePdfJs) {
await ref
.read(preferenceFixatorProvider.notifier)
.register('pdfjs.disabled', !settings.enablePdfJs);
}
if (!const DeepCollectionEquality.unordered().equals(
previous.value?.locales,
settings.locales,
)) {
await ref
.read(preferenceFixatorProvider.notifier)
.register(
'intl.accept_languages',
settings.locales.join(','),
);
}
// Web Content Settings
if (previous.value?.webFontsEnabled != settings.webFontsEnabled) {
await _service.webFontsEnabled(settings.webFontsEnabled);
}
if (previous.value?.automaticFontSizeAdjustment !=
settings.automaticFontSizeAdjustment) {
await _service.automaticFontSizeAdjustment(
settings.automaticFontSizeAdjustment,
);
}
if (previous.value?.fontSizeFactor != settings.fontSizeFactor) {
await _service.fontSizeFactor(settings.fontSizeFactor);
}
if (previous.value?.fontInflationEnabled !=
settings.fontInflationEnabled) {
await _service.fontInflationEnabled(
settings.fontInflationEnabled,
);
}
if (previous.value?.inputAutoZoomEnabled !=
settings.inputAutoZoomEnabled) {
await _service.inputAutoZoomEnabled(
settings.inputAutoZoomEnabled,
);
}
// LNA Settings
if (previous.value?.lnaBlocking != settings.lnaBlocking) {
await _service.lnaBlocking(settings.lnaBlocking);
}
if (previous.value?.lnaBlockTrackers != settings.lnaBlockTrackers) {
await _service.lnaBlockTrackers(settings.lnaBlockTrackers);
}
if (previous.value?.lnaEnabled != settings.lnaEnabled) {
await _service.lnaEnabled(settings.lnaEnabled);
}
} else {
await _service.setDefaultSettings(settings);
await ref
.read(startupPreferenceEnforcementServiceProvider.notifier)
.apply();
await ref
.read(preferenceFixatorProvider.notifier)
.register('pdfjs.disabled', !settings.enablePdfJs);
await ref
.read(preferenceFixatorProvider.notifier)
.register('intl.accept_languages', settings.locales.join(','));
// Initialize unsigned extensions fixator from Gecko pref
await ref.read(allowUnsignedExtensionsProvider.future);
initialSettingsSent = true;
}
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to engineSettingsRepositoryProvider',
error: error,
stackTrace: stackTrace,
);
},
);
}
}
@@ -0,0 +1,65 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'engine_settings_replication.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(EngineSettingsReplicationService)
final engineSettingsReplicationServiceProvider =
EngineSettingsReplicationServiceProvider._();
final class EngineSettingsReplicationServiceProvider
extends $NotifierProvider<EngineSettingsReplicationService, void> {
EngineSettingsReplicationServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'engineSettingsReplicationServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$engineSettingsReplicationServiceHash();
@$internal
@override
EngineSettingsReplicationService create() =>
EngineSettingsReplicationService();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$engineSettingsReplicationServiceHash() =>
r'81443c37d18ad82f0694cd0e1115ccdf8a0f4c06';
abstract class _$EngineSettingsReplicationService extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,255 @@
/*
* 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 'package:fast_equatable/fast_equatable.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/providers.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/tor/domain/repositories/tor_proxy.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/features/user/data/models/tor_settings.dart';
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
part 'proxy_settings_replication.g.dart';
@Riverpod(keepAlive: true)
class ProxySettingsReplication extends _$ProxySettingsReplication {
var _proxiedIsolationContexts = <String>{};
final _recomputeLock = Lock();
Future<void> _queueIsolatedProxyAliasesRecompute() async {
if (_recomputeLock.inLock) {
return;
}
await _recomputeLock.synchronized(() async {
try {
await _recomputeIsolatedProxyAliases();
} catch (error, stackTrace) {
logger.e(
'Error recomputing isolated proxy aliases',
error: error,
stackTrace: stackTrace,
);
}
});
}
Future<void> _recomputeIsolatedProxyAliases() async {
final db = ref.read(tabDatabaseProvider);
final contextContainerMap = <String, Set<String>>{};
final pairs = await db.tabDao.isolatedContextContainerPairs().get();
for (final pair in pairs) {
final isolationContextId = pair.isolationContextId;
final containerId = pair.containerId;
if (isolationContextId == null || containerId == null) continue;
contextContainerMap
.putIfAbsent(isolationContextId, () => <String>{})
.add(containerId);
}
final containers = await ref
.read(containerRepositoryProvider.notifier)
.getAllContainersWithCount();
// Build set of container IDs that have useProxy enabled.
final proxiedContainerIds = <String>{
for (final c in containers)
if (c.metadata.useProxy) c.id,
};
// Compute which isolation contexts need proxy aliases.
// A context needs an alias if ANY of its associated containers
// has useProxy enabled.
final newProxied = <String>{
for (final entry in contextContainerMap.entries)
if (entry.value.any(proxiedContainerIds.contains)) entry.key,
};
// Remove aliases that are no longer needed.
final toRemove = _proxiedIsolationContexts.difference(newProxied);
for (final contextId in toRemove) {
await ref
.read(torProxyRepositoryProvider.notifier)
.removeContainerProxy(contextId);
}
// Add aliases that are newly needed.
final toAdd = newProxied.difference(_proxiedIsolationContexts);
for (final contextId in toAdd) {
await ref
.read(torProxyRepositoryProvider.notifier)
.addContainerProxy(contextId);
}
_proxiedIsolationContexts = newProxied;
}
@override
void build() {
ref.listen(
fireImmediately: true,
torProxyServiceProvider.select((data) => data.value),
(previous, next) async {
await ref
.read(torProxyRepositoryProvider.notifier)
.setProxyPort(next?.socksPort ?? -1);
},
onError: (error, stackTrace) {
logger.e(
'Error listening to torProxyServiceProvider',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listen(
fireImmediately: true,
watchContainersWithCountProvider.select(
(value) => EquatableValue(value.value),
),
(previous, next) async {
if (next.value != null) {
for (final container in next.value!) {
if (container.metadata.contextualIdentity.isNotEmpty) {
if (container.metadata.useProxy) {
await ref
.read(torProxyRepositoryProvider.notifier)
.addContainerProxy(container.metadata.contextualIdentity!);
} else {
await ref
.read(torProxyRepositoryProvider.notifier)
.removeContainerProxy(
container.metadata.contextualIdentity!,
);
}
}
}
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to containersWithCountProvider',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listen(
fireImmediately: true,
torSettingsWithDefaultsProvider.select(
(value) => value.proxyPrivateTabsTor,
),
(previous, next) async {
if (next) {
await ref
.read(torProxyRepositoryProvider.notifier)
.addContainerProxy('private');
} else {
await ref
.read(torProxyRepositoryProvider.notifier)
.removeContainerProxy('private');
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to generalSettingsRepositoryProvider',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listen(
fireImmediately: true,
torSettingsWithDefaultsProvider.select(
(value) => value.proxyRegularTabsMode,
),
(previous, next) async {
switch (next) {
case TorRegularTabProxyMode.container:
await ref
.read(torProxyRepositoryProvider.notifier)
.removeContainerProxy('general');
case TorRegularTabProxyMode.all:
await ref
.read(torProxyRepositoryProvider.notifier)
.addContainerProxy('general');
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to generalSettingsRepositoryProvider',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listen(watchAllAssignedSitesProvider, (previous, next) async {
if (next.hasValue) {
await ref
.read(torProxyRepositoryProvider.notifier)
.setSiteAssignments(next.requireValue);
}
});
ref.listen(
fireImmediately: true,
watchIsolatedContextContainerMapProvider.select(
(value) => EquatableValue(value.value),
),
(previous, next) => _queueIsolatedProxyAliasesRecompute(),
onError: (error, stackTrace) {
logger.e(
'Error listening to isolated context proxy aliases',
error: error,
stackTrace: stackTrace,
);
},
);
ref.listen(
fireImmediately: true,
watchContainersWithCountProvider.select(
(value) => EquatableValue(value.value),
),
(previous, next) => _queueIsolatedProxyAliasesRecompute(),
onError: (error, stackTrace) {
logger.e(
'Error listening to container proxy changes for isolated aliases',
error: error,
stackTrace: stackTrace,
);
},
);
}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'proxy_settings_replication.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ProxySettingsReplication)
final proxySettingsReplicationProvider = ProxySettingsReplicationProvider._();
final class ProxySettingsReplicationProvider
extends $NotifierProvider<ProxySettingsReplication, void> {
ProxySettingsReplicationProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'proxySettingsReplicationProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$proxySettingsReplicationHash();
@$internal
@override
ProxySettingsReplication create() => ProxySettingsReplication();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$proxySettingsReplicationHash() =>
r'7f47a561441cc2d594e9380d62dcc31f4c1059d3';
abstract class _$ProxySettingsReplication extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,82 @@
/*
* 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 'package:fast_equatable/fast_equatable.dart';
import 'package:lexo_rank/lexo_rank.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/repositories/contextual_toolbar_config_repository.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ToolbarButtonConfig;
part 'toolbar_button_configs.g.dart';
List<ToolbarButtonConfig> _buildDefaultToolbarButtonConfigs() {
String? lastKey;
return toolbarButtonSpecs.map((spec) {
final key = lastKey == null
? LexoRank.middle().value
: LexoRank.parse(lastKey!).genNext().value;
lastKey = key;
return ToolbarButtonConfig(
buttonId: spec.id.name,
orderKey: key,
isVisible: spec.defaultVisible,
fallbackId: spec.defaultFallback?.name,
);
}).toList();
}
final defaultToolbarButtonConfigs = EquatableValue(
_buildDefaultToolbarButtonConfigs(),
);
@Riverpod(keepAlive: true)
Stream<List<ToolbarButtonConfig>> toolbarButtonConfigs(Ref ref) async* {
final repository = ref.watch(contextualToolbarConfigRepositoryProvider);
await repository.seedMissingDefaults();
yield* repository.watchAll();
}
@Riverpod(keepAlive: true)
EquatableValue<List<ToolbarButtonConfig>> effectiveToolbarButtonConfigs(
Ref ref,
) {
final configsAsync = ref.watch(toolbarButtonConfigsProvider);
return configsAsync.when(
data: (configs) {
final filtered = configs
.where((config) => knownToolbarButtonIds.contains(config.buttonId))
.toList();
if (filtered.isEmpty) {
return defaultToolbarButtonConfigs;
}
return EquatableValue(filtered);
},
loading: () => defaultToolbarButtonConfigs,
error: (_, _) => defaultToolbarButtonConfigs,
);
}
@@ -0,0 +1,102 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'toolbar_button_configs.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(toolbarButtonConfigs)
final toolbarButtonConfigsProvider = ToolbarButtonConfigsProvider._();
final class ToolbarButtonConfigsProvider
extends
$FunctionalProvider<
AsyncValue<List<ToolbarButtonConfig>>,
List<ToolbarButtonConfig>,
Stream<List<ToolbarButtonConfig>>
>
with
$FutureModifier<List<ToolbarButtonConfig>>,
$StreamProvider<List<ToolbarButtonConfig>> {
ToolbarButtonConfigsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'toolbarButtonConfigsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$toolbarButtonConfigsHash();
@$internal
@override
$StreamProviderElement<List<ToolbarButtonConfig>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<ToolbarButtonConfig>> create(Ref ref) {
return toolbarButtonConfigs(ref);
}
}
String _$toolbarButtonConfigsHash() =>
r'02f79c2087a3a5413fe879405c05f4a4d1eaffeb';
@ProviderFor(effectiveToolbarButtonConfigs)
final effectiveToolbarButtonConfigsProvider =
EffectiveToolbarButtonConfigsProvider._();
final class EffectiveToolbarButtonConfigsProvider
extends
$FunctionalProvider<
EquatableValue<List<ToolbarButtonConfig>>,
EquatableValue<List<ToolbarButtonConfig>>,
EquatableValue<List<ToolbarButtonConfig>>
>
with $Provider<EquatableValue<List<ToolbarButtonConfig>>> {
EffectiveToolbarButtonConfigsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'effectiveToolbarButtonConfigsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$effectiveToolbarButtonConfigsHash();
@$internal
@override
$ProviderElement<EquatableValue<List<ToolbarButtonConfig>>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
EquatableValue<List<ToolbarButtonConfig>> create(Ref ref) {
return effectiveToolbarButtonConfigs(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EquatableValue<List<ToolbarButtonConfig>> value) {
return $ProviderOverride(
origin: this,
providerOverride:
$SyncValueProvider<EquatableValue<List<ToolbarButtonConfig>>>(value),
);
}
}
String _$effectiveToolbarButtonConfigsHash() =>
r'dd876160f586c64237a42a29eb63cb119f962b02';
@@ -0,0 +1,89 @@
/*
* 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 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart';
import 'package:weblibre/features/user/data/database/daos/toolbar_button_config.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ToolbarButtonConfig;
import 'package:weblibre/features/user/data/providers.dart';
part 'contextual_toolbar_config_repository.g.dart';
class ContextualToolbarConfigRepository {
ContextualToolbarConfigRepository(this._dao);
final ToolbarButtonConfigDao _dao;
Stream<List<ToolbarButtonConfig>> watchAll() => _dao.watchAll();
Future<void> replaceAll(List<ToolbarButtonConfig> configs) {
return _dao.replaceAll(configs);
}
Future<void> assignOrderKey(String buttonId, {required String orderKey}) {
return _dao.assignOrderKey(buttonId, orderKey: orderKey);
}
Future<void> assignVisibility(String buttonId, {required bool visible}) {
return _dao.assignVisibility(buttonId, visible: visible);
}
Future<void> assignFallback(String buttonId, String? fallbackId) {
return _dao.assignFallback(buttonId, fallbackId);
}
Future<String> generateLeadingOrderKey() {
return _dao.generateLeadingOrderKey().getSingle();
}
Future<String> generateTrailingOrderKey() {
return _dao.generateTrailingOrderKey().getSingle();
}
Future<String?> generateOrderKeyAfterButtonId(String buttonId) {
return _dao.generateOrderKeyAfterButtonId(buttonId).getSingleOrNull();
}
Future<String> generateOrderKeyBeforeButtonId(String buttonId) {
return _dao.generateOrderKeyBeforeButtonId(buttonId).getSingle();
}
Future<void> seedMissingDefaults() {
return _dao.seedMissing(
toolbarButtonSpecs
.map(
(spec) => (
buttonId: spec.id.name,
defaultVisible: spec.defaultVisible,
defaultFallback: spec.defaultFallback?.name,
),
)
.toList(),
);
}
}
@Riverpod(keepAlive: true)
ContextualToolbarConfigRepository contextualToolbarConfigRepository(Ref ref) {
return ContextualToolbarConfigRepository(
ref.watch(userDatabaseProvider).toolbarButtonConfigDao,
);
}
@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'contextual_toolbar_config_repository.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(contextualToolbarConfigRepository)
final contextualToolbarConfigRepositoryProvider =
ContextualToolbarConfigRepositoryProvider._();
final class ContextualToolbarConfigRepositoryProvider
extends
$FunctionalProvider<
ContextualToolbarConfigRepository,
ContextualToolbarConfigRepository,
ContextualToolbarConfigRepository
>
with $Provider<ContextualToolbarConfigRepository> {
ContextualToolbarConfigRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'contextualToolbarConfigRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() =>
_$contextualToolbarConfigRepositoryHash();
@$internal
@override
$ProviderElement<ContextualToolbarConfigRepository> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
ContextualToolbarConfigRepository create(Ref ref) {
return contextualToolbarConfigRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ContextualToolbarConfigRepository value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ContextualToolbarConfigRepository>(
value,
),
);
}
}
String _$contextualToolbarConfigRepositoryHash() =>
r'96bb0c00019e076ef65d3e901ba850de0fccbb1b';
@@ -0,0 +1,46 @@
/*
* 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 ToolbarButtonId {
back,
forward,
bookmarks,
bookmarkToggle,
share,
addTab,
tabsCount,
navigationMenu,
reload,
readerMode,
desktop,
translation,
findInPage,
closeTab,
inputUrl,
duplicateTab,
increaseFont,
decreaseFont,
moveToBackground,
pageUp,
pageDown,
font,
extensionShortcut,
quit,
}
@@ -0,0 +1,198 @@
/*
* 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 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart';
class ToolbarButtonSpec {
final ToolbarButtonId id;
final bool defaultVisible;
final ToolbarButtonId? defaultFallback;
final bool canBeFallbackTarget;
const ToolbarButtonSpec({
required this.id,
required this.defaultVisible,
this.defaultFallback,
this.canBeFallbackTarget = true,
});
}
const backToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.back,
defaultVisible: true,
defaultFallback: ToolbarButtonId.bookmarks,
);
const forwardToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.forward,
defaultVisible: true,
defaultFallback: ToolbarButtonId.share,
);
const bookmarksToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.bookmarks,
defaultVisible: false,
);
const bookmarkToggleToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.bookmarkToggle,
defaultVisible: false,
);
const shareToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.share,
defaultVisible: false,
);
const addTabToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.addTab,
defaultVisible: true,
);
const tabsCountToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.tabsCount,
defaultVisible: true,
);
const navigationMenuToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.navigationMenu,
defaultVisible: true,
);
const reloadToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.reload,
defaultVisible: false,
);
const readerModeToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.readerMode,
defaultVisible: false,
canBeFallbackTarget: false,
);
const desktopToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.desktop,
defaultVisible: false,
);
const translationToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.translation,
defaultVisible: false,
canBeFallbackTarget: false,
);
const findInPageToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.findInPage,
defaultVisible: false,
);
const closeTabToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.closeTab,
defaultVisible: false,
canBeFallbackTarget: false,
);
const inputUrlToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.inputUrl,
defaultVisible: false,
);
const duplicateTabToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.duplicateTab,
defaultVisible: false,
);
const increaseFontToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.increaseFont,
defaultVisible: false,
);
const decreaseFontToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.decreaseFont,
defaultVisible: false,
);
const moveToBackgroundToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.moveToBackground,
defaultVisible: false,
canBeFallbackTarget: false,
);
const pageUpToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.pageUp,
defaultVisible: false,
canBeFallbackTarget: false,
);
const pageDownToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.pageDown,
defaultVisible: false,
canBeFallbackTarget: false,
);
const fontToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.font,
defaultVisible: false,
);
const extensionShortcutToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.extensionShortcut,
defaultVisible: false,
canBeFallbackTarget: false,
);
const quitToolbarButtonSpec = ToolbarButtonSpec(
id: ToolbarButtonId.quit,
defaultVisible: false,
canBeFallbackTarget: false,
);
const toolbarButtonSpecs = [
backToolbarButtonSpec,
forwardToolbarButtonSpec,
bookmarksToolbarButtonSpec,
bookmarkToggleToolbarButtonSpec,
shareToolbarButtonSpec,
addTabToolbarButtonSpec,
tabsCountToolbarButtonSpec,
navigationMenuToolbarButtonSpec,
reloadToolbarButtonSpec,
readerModeToolbarButtonSpec,
desktopToolbarButtonSpec,
translationToolbarButtonSpec,
findInPageToolbarButtonSpec,
closeTabToolbarButtonSpec,
inputUrlToolbarButtonSpec,
duplicateTabToolbarButtonSpec,
increaseFontToolbarButtonSpec,
decreaseFontToolbarButtonSpec,
moveToBackgroundToolbarButtonSpec,
pageUpToolbarButtonSpec,
pageDownToolbarButtonSpec,
fontToolbarButtonSpec,
extensionShortcutToolbarButtonSpec,
quitToolbarButtonSpec,
];
final Map<String, ToolbarButtonSpec> toolbarButtonSpecsById = {
for (final spec in toolbarButtonSpecs) spec.id.name: spec,
};
final Set<String> knownToolbarButtonIds = toolbarButtonSpecsById.keys.toSet();
@@ -0,0 +1,57 @@
/*
* 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 'package:fast_equatable/fast_equatable.dart';
sealed class ToolbarFallbackChoice with FastEquatable {
ToolbarFallbackChoice();
factory ToolbarFallbackChoice.fromStored(String? storedFallbackId) {
if (storedFallbackId != null) {
return ToolbarFallbackButton(buttonId: storedFallbackId);
}
return ToolbarFallbackNone();
}
String? resolveRuntimeFallbackId();
String? toStoredFallbackId() => resolveRuntimeFallbackId();
}
class ToolbarFallbackButton extends ToolbarFallbackChoice {
final String buttonId;
ToolbarFallbackButton({required this.buttonId});
@override
List<Object?> get hashParameters => [buttonId];
@override
String resolveRuntimeFallbackId() => buttonId;
}
class ToolbarFallbackNone extends ToolbarFallbackChoice {
ToolbarFallbackNone();
@override
List<Object?> get hashParameters => [null];
@override
String? resolveRuntimeFallbackId() => null;
}
@@ -0,0 +1,116 @@
/*
* 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 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_fallback_choice.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart';
class ContextualToolbarButtonResolution {
const ContextualToolbarButtonResolution({
required this.buttonId,
required this.isEnabled,
});
final String buttonId;
final bool isEnabled;
}
List<ContextualToolbarButtonResolution> resolveVisibleContextualToolbarButtons({
required List<ToolbarButtonConfig> configs,
required Set<String> knownButtonIds,
required bool Function(String buttonId) isPrimaryAvailable,
}) {
final configById = {for (final config in configs) config.buttonId: config};
final resolvedButtons = <ContextualToolbarButtonResolution>[];
final seenIds = <String>{};
for (final config in configs.where((config) => config.isVisible)) {
final resolvedButton = resolveContextualToolbarButton(
config: config,
configById: configById,
knownButtonIds: knownButtonIds,
isPrimaryAvailable: isPrimaryAvailable,
);
if (resolvedButton == null) {
continue;
}
if (seenIds.add(resolvedButton.buttonId)) {
resolvedButtons.add(resolvedButton);
}
}
return resolvedButtons;
}
ContextualToolbarButtonResolution? resolveContextualToolbarButton({
required ToolbarButtonConfig config,
required Map<String, ToolbarButtonConfig> configById,
required Set<String> knownButtonIds,
required bool Function(String buttonId) isPrimaryAvailable,
Set<String> visited = const {},
}) {
if (visited.contains(config.buttonId)) {
return null;
}
if (!knownButtonIds.contains(config.buttonId)) {
return ContextualToolbarButtonResolution(
buttonId: config.buttonId,
isEnabled: true,
);
}
if (isPrimaryAvailable(config.buttonId)) {
return ContextualToolbarButtonResolution(
buttonId: config.buttonId,
isEnabled: true,
);
}
final fallbackId = ToolbarFallbackChoice.fromStored(
config.fallbackId,
).resolveRuntimeFallbackId();
if (fallbackId == null) {
return ContextualToolbarButtonResolution(
buttonId: config.buttonId,
isEnabled: false,
);
}
final fallbackConfig = configById[fallbackId];
if (fallbackConfig == null) {
return knownButtonIds.contains(fallbackId)
? ContextualToolbarButtonResolution(
buttonId: fallbackId,
isEnabled: true,
)
: ContextualToolbarButtonResolution(
buttonId: config.buttonId,
isEnabled: false,
);
}
return resolveContextualToolbarButton(
config: fallbackConfig,
configById: configById,
knownButtonIds: knownButtonIds,
isPrimaryAvailable: isPrimaryAvailable,
visited: {...visited, config.buttonId},
);
}
@@ -0,0 +1,44 @@
/*
* 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 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
class ContextualToolbarScope with FastEquatable {
final String? selectedTabId;
final Sheet? displayedSheet;
final TabState? tabState;
final bool isPreview;
ContextualToolbarScope({
required this.selectedTabId,
required this.displayedSheet,
required this.tabState,
required this.isPreview,
});
@override
List<Object?> get hashParameters => [
selectedTabId,
displayedSheet,
tabState,
isPreview,
];
}
@@ -0,0 +1,997 @@
/*
* 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 'package:fast_equatable/fast_equatable.dart';
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:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/font_size_constants.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_close_confirmation.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_shortcut_menu.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/font_size_bottom_sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/utils/exit_app.dart';
import 'package:weblibre/utils/move_to_background.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class ToolbarButtonDefinition {
final ToolbarButtonSpec spec;
final String label;
final IconData icon;
final bool Function(ContextualToolbarScope scope, WidgetRef ref)?
isPrimaryAvailable;
final Widget Function(
ContextualToolbarScope scope,
BuildContext context,
WidgetRef ref,
)
builder;
final List<String> longPressActions;
const ToolbarButtonDefinition({
required this.spec,
required this.label,
required this.icon,
this.isPrimaryAvailable,
required this.builder,
this.longPressActions = const [],
});
}
final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
ToolbarButtonDefinition(
spec: backToolbarButtonSpec,
label: 'Back',
icon: Icons.arrow_back,
isPrimaryAvailable: (scope, ref) =>
scope.tabState?.historyState.canGoBack == true ||
scope.tabState?.isLoading == true,
longPressActions: ['History Menu (Previous pages)'],
builder: (scope, context, ref) {
if (scope.isPreview) {
return NavigateBackButtonView(
canGoBack: true,
isLoading: false,
onPressed: () {},
onLongPress: () {},
);
}
return NavigateBackButton(
selectedTabId: scope.selectedTabId,
isLoading: scope.tabState?.isLoading ?? false,
);
},
),
ToolbarButtonDefinition(
spec: forwardToolbarButtonSpec,
label: 'Forward',
icon: Icons.arrow_forward,
isPrimaryAvailable: (scope, ref) =>
scope.tabState?.historyState.canGoForward == true,
longPressActions: ['History Menu (Forward pages)'],
builder: (scope, context, ref) {
if (scope.isPreview) {
return NavigateForwardButtonView(
canGoForward: true,
onPressed: () {},
onLongPress: () {},
);
}
return NavigateForwardButton(selectedTabId: scope.selectedTabId);
},
),
ToolbarButtonDefinition(
spec: bookmarksToolbarButtonSpec,
label: 'Bookmarks',
icon: MdiIcons.bookmarkMultiple,
longPressActions: ['Add Bookmark', 'Remove Bookmark'],
builder: (scope, context, ref) => _BookmarkToolbarButton(scope: scope),
),
ToolbarButtonDefinition(
spec: bookmarkToggleToolbarButtonSpec,
label: 'Bookmark',
icon: Icons.bookmark_border,
longPressActions: ['Open Bookmarks'],
builder: (scope, context, ref) =>
_BookmarkToggleToolbarButton(scope: scope),
),
ToolbarButtonDefinition(
spec: shareToolbarButtonSpec,
label: 'Share',
icon: Icons.share,
isPrimaryAvailable: (scope, ref) => scope.selectedTabId != null,
builder: (scope, context, ref) => scope.isPreview
? ShareMenuButtonView(onPressed: () {})
: ShareMenuButton(selectedTabId: scope.selectedTabId),
),
ToolbarButtonDefinition(
spec: addTabToolbarButtonSpec,
label: 'New Tab',
icon: MdiIcons.tabPlus,
longPressActions: [
'Add Regular Tab',
'Add Child Tab',
'Add Private Tab',
'Add Isolated Tab',
],
builder: (scope, context, ref) => scope.isPreview
? AddTabButtonView(onPressed: () {}, onLongPress: () {})
: const AddTabButton(),
),
ToolbarButtonDefinition(
spec: tabsCountToolbarButtonSpec,
label: 'Tabs',
icon: MdiIcons.tab,
longPressActions: [
'Add Regular Tab',
'Add Child Tab',
'Add Private Tab',
'Add Isolated Tab',
],
builder: (scope, context, ref) => scope.isPreview
? TabsCountButtonView(
isActive: false,
onTap: () {},
onLongPress: () {},
buttonBuilder: (isActive, onTap, onLongPress) {
return TabsActionButtonView(
isActive: isActive,
tabCountText: '5',
onTap: onTap,
onLongPress: onLongPress,
);
},
)
: TabsCountButton(
selectedTabId: scope.selectedTabId,
displayedSheet: scope.displayedSheet,
showLongPressMenu: false,
),
),
ToolbarButtonDefinition(
spec: navigationMenuToolbarButtonSpec,
label: 'Menu',
icon: Icons.more_vert,
builder: (scope, context, ref) => scope.isPreview
? NavigationMenuButtonView(onTap: () {})
: NavigationMenuButton(selectedTabId: scope.selectedTabId),
),
ToolbarButtonDefinition(
spec: reloadToolbarButtonSpec,
label: 'Reload',
icon: Icons.refresh,
longPressActions: ['Hard Refresh (bypass cache)'],
isPrimaryAvailable: (scope, ref) => scope.selectedTabId != null,
builder: (scope, context, ref) => _ReloadToolbarButton(scope: scope),
),
ToolbarButtonDefinition(
spec: readerModeToolbarButtonSpec,
label: 'Reader Mode',
icon: MdiIcons.bookOpenOutline,
isPrimaryAvailable: (scope, ref) {
final readerableState =
scope.tabState?.readerableState ?? ReaderableState.$default();
final enableReadability = ref.read(
generalSettingsWithDefaultsProvider.select(
(value) => value.enableReadability,
),
);
final enforceReadability = ref.read(
generalSettingsWithDefaultsProvider.select(
(value) => value.enforceReadability,
),
);
return (readerableState.readerable &&
(enableReadability || readerableState.active)) ||
(enforceReadability && enableReadability);
},
builder: (scope, context, ref) {
if (scope.isPreview) {
return IconButton(
onPressed: () {},
icon: const Icon(MdiIcons.bookOpenOutline),
);
}
return _ReaderModeToolbarButton(selectedTabId: scope.selectedTabId);
},
),
ToolbarButtonDefinition(
spec: desktopToolbarButtonSpec,
label: 'Desktop Site',
icon: Icons.desktop_windows,
isPrimaryAvailable: (scope, ref) => scope.selectedTabId != null,
builder: (scope, context, ref) {
if (scope.isPreview) {
return IconButton(
onPressed: () {},
icon: const Icon(Icons.desktop_windows_outlined),
);
}
return _DesktopModeToolbarButton(selectedTabId: scope.selectedTabId);
},
),
ToolbarButtonDefinition(
spec: translationToolbarButtonSpec,
label: 'Translate',
icon: Icons.translate,
longPressActions: ['Show Translation Options'],
isPrimaryAvailable: (scope, ref) {
if (scope.selectedTabId == null) {
return false;
}
final engineState = ref.read(translationEngineStateProvider);
final readerActive = scope.tabState?.readerableState.active ?? false;
return !readerActive && engineState?.isEngineSupported == true;
},
builder: (scope, context, ref) {
if (scope.isPreview) {
return IconButton(onPressed: () {}, icon: const Icon(Icons.translate));
}
return _TranslateToolbarButton(selectedTabId: scope.selectedTabId);
},
),
ToolbarButtonDefinition(
spec: findInPageToolbarButtonSpec,
label: 'Find in Page',
icon: Icons.search,
isPrimaryAvailable: (scope, ref) => scope.selectedTabId != null,
builder: (scope, context, ref) {
return IconButton(
onPressed: scope.isPreview
? () {}
: () {
final tabId = scope.selectedTabId;
if (tabId != null) {
ref.read(findInPageControllerProvider(tabId).notifier).show();
}
},
icon: const Icon(Icons.search),
);
},
),
ToolbarButtonDefinition(
spec: closeTabToolbarButtonSpec,
label: 'Close Tab',
icon: MdiIcons.tabMinus,
longPressActions: ['Close Others', 'Close from Same Host'],
isPrimaryAvailable: (scope, ref) => scope.selectedTabId != null,
builder: (scope, context, ref) => _CloseTabToolbarButton(scope: scope),
),
ToolbarButtonDefinition(
spec: inputUrlToolbarButtonSpec,
label: 'Address Bar',
icon: Icons.edit,
builder: (scope, context, ref) {
return IconButton(
onPressed: scope.isPreview
? () {}
: () async {
final tabState = scope.tabState;
if (tabState != null) {
final searchText = tabState.url.scheme == 'about'
? SearchRoute.emptySearchText
: tabState.url.toString();
await SearchRoute(
tabId: tabState.id,
searchText: searchText.isEmpty
? SearchRoute.emptySearchText
: searchText,
tabType: tabState.tabMode.toTabType(),
).push(context);
} else {
await const SearchRoute(
tabType: TabType.regular,
).push(context);
}
},
icon: const Icon(Icons.edit),
);
},
),
ToolbarButtonDefinition(
spec: duplicateTabToolbarButtonSpec,
label: 'Duplicate Tab',
icon: MdiIcons.contentDuplicate,
longPressActions: [
'Clone as Regular',
'Clone as Private',
'Clone as Isolated',
],
isPrimaryAvailable: (scope, ref) => scope.selectedTabId != null,
builder: (scope, context, ref) {
return scope.isPreview
? CloneTabButtonView(onPressed: () {}, onLongPress: () {})
: CloneTabButton(selectedTabId: scope.selectedTabId);
},
),
ToolbarButtonDefinition(
spec: increaseFontToolbarButtonSpec,
label: 'Increase Font',
icon: MdiIcons.formatFontSizeIncrease,
builder: (scope, context, ref) {
return IconButton(
onPressed: scope.isPreview
? () {}
: () => _adjustFontSize(context, ref, increase: true),
icon: const Icon(MdiIcons.formatFontSizeIncrease),
);
},
),
ToolbarButtonDefinition(
spec: decreaseFontToolbarButtonSpec,
label: 'Decrease Font',
icon: MdiIcons.formatFontSizeDecrease,
builder: (scope, context, ref) {
return IconButton(
onPressed: scope.isPreview
? () {}
: () => _adjustFontSize(context, ref, increase: false),
icon: const Icon(MdiIcons.formatFontSizeDecrease),
);
},
),
ToolbarButtonDefinition(
spec: moveToBackgroundToolbarButtonSpec,
label: 'Background',
icon: MdiIcons.arrowCollapseDown,
builder: (scope, context, ref) {
return IconButton(
onPressed: scope.isPreview ? () {} : moveToBackground,
icon: const Icon(MdiIcons.arrowCollapseDown),
);
},
),
ToolbarButtonDefinition(
spec: pageUpToolbarButtonSpec,
label: 'Page Up',
icon: MdiIcons.chevronDoubleUp,
longPressActions: ['Scroll to Top'],
isPrimaryAvailable: (scope, ref) => scope.selectedTabId != null,
builder: (scope, context, ref) {
return IconButton(
onPressed: scope.isPreview
? () {}
: () async {
final tabId = scope.selectedTabId;
if (tabId != null) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.pageUp();
}
},
onLongPress: scope.isPreview
? () {}
: () async {
final tabId = scope.selectedTabId;
if (tabId != null) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.scrollToTop();
}
},
icon: const Icon(MdiIcons.chevronDoubleUp),
);
},
),
ToolbarButtonDefinition(
spec: pageDownToolbarButtonSpec,
label: 'Page Down',
icon: MdiIcons.chevronDoubleDown,
longPressActions: ['Scroll to Bottom'],
isPrimaryAvailable: (scope, ref) => scope.selectedTabId != null,
builder: (scope, context, ref) {
return IconButton(
onPressed: scope.isPreview
? () {}
: () async {
final tabId = scope.selectedTabId;
if (tabId != null) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.pageDown();
}
},
onLongPress: scope.isPreview
? () {}
: () async {
final tabId = scope.selectedTabId;
if (tabId != null) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.scrollToBottom();
}
},
icon: const Icon(MdiIcons.chevronDoubleDown),
);
},
),
ToolbarButtonDefinition(
spec: fontToolbarButtonSpec,
label: 'Text Size',
icon: MdiIcons.formatSize,
builder: (scope, context, ref) {
if (scope.isPreview) {
return IconButton(
onPressed: () {},
icon: const Icon(MdiIcons.formatSize),
);
}
return _FontToolbarButton(selectedTabId: scope.selectedTabId);
},
),
ToolbarButtonDefinition(
spec: extensionShortcutToolbarButtonSpec,
label: 'Extensions',
icon: MdiIcons.puzzle,
longPressActions: ['Extensions Menu'],
isPrimaryAvailable: (scope, ref) => ref
.read(
webExtensionsStateProvider(
WebExtensionActionType.browser,
).select((value) => value.values),
)
.isNotEmpty,
builder: (scope, context, ref) {
if (scope.isPreview) {
return IconButton(onPressed: () {}, icon: const Icon(MdiIcons.puzzle));
}
return const _ExtensionShortcutToolbarButton();
},
),
ToolbarButtonDefinition(
spec: quitToolbarButtonSpec,
label: 'Quit',
icon: MdiIcons.power,
longPressActions: ['Quit without confirmation'],
builder: (scope, context, ref) {
return IconButton(
onPressed: scope.isPreview
? () {}
: () async {
final result = await showQuitBrowserDialog(context);
if (result == true) {
await exitApp(ref.container);
}
},
onLongPress: scope.isPreview
? null
: () async {
await exitApp(ref.container);
},
icon: const Icon(MdiIcons.power),
);
},
),
];
final Map<String, ToolbarButtonDefinition> toolbarButtonRegistryById = {
for (final def in toolbarButtonRegistry) def.spec.id.name: def,
};
Future<void> _closeTab(
BuildContext context,
WidgetRef ref,
String? selectedTabId,
) async {
if (selectedTabId == null) return;
final tabState = ref.read(tabStateProvider(selectedTabId));
if (tabState != null && tabState.tabMode is IsolatedTabMode) {
final allStates = ref.read(tabStatesProvider);
final groupCount = allStates.values
.where((s) => s.isolationContextId == tabState.isolationContextId)
.length;
if (groupCount <= 1 && context.mounted) {
final confirmed = await ui_helper.confirmIsolatedTabClose(context);
if (!confirmed) return;
}
}
await ref.read(tabRepositoryProvider.notifier).closeTab(selectedTabId);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
);
}
}
class _ExtensionShortcutToolbarButton extends HookConsumerWidget {
const _ExtensionShortcutToolbarButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final menuController = useMemoized(MenuController.new);
return ExtensionShortcutMenu(
controller: menuController,
child: IconButton(
onPressed: () {
if (menuController.isOpen) {
menuController.close();
} else {
menuController.open();
}
},
icon: const Icon(MdiIcons.puzzle),
),
);
}
}
class _ReloadToolbarButton extends HookConsumerWidget {
final ContextualToolbarScope scope;
const _ReloadToolbarButton({required this.scope});
@override
Widget build(BuildContext context, WidgetRef ref) {
final menuController = useMemoized(MenuController.new);
return MenuAnchor(
controller: menuController,
builder: (context, controller, child) => child!,
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.refresh),
onPressed: () async {
final tabId = scope.selectedTabId;
if (tabId != null) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.reload(flags: LoadUrlFlags.BYPASS_CACHE);
}
},
child: const Text('Hard Refresh'),
),
],
child: IconButton(
onPressed: scope.isPreview
? () {}
: () async {
final tabId = scope.selectedTabId;
if (tabId != null) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.reload();
}
},
onLongPress: scope.isPreview
? null
: () {
if (menuController.isOpen) {
menuController.close();
} else {
menuController.open();
}
},
icon: const Icon(Icons.refresh),
),
);
}
}
class _CloseTabToolbarButton extends HookConsumerWidget {
final ContextualToolbarScope scope;
const _CloseTabToolbarButton({required this.scope});
@override
Widget build(BuildContext context, WidgetRef ref) {
final menuController = useMemoized(MenuController.new);
final host = ref.watch(
tabStateProvider(scope.selectedTabId).select((s) => s?.url.host),
);
return MenuAnchor(
controller: menuController,
builder: (context, controller, child) => child!,
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(Icons.tab),
onPressed: () async {
final tabStates = ref.read(tabStatesProvider);
final otherIds = tabStates.keys
.where((id) => id != scope.selectedTabId)
.toList();
if (otherIds.isNotEmpty) {
await closeTabsWithConfirmation(context, ref, otherIds);
}
},
child: const Text('Close Others'),
),
if (host != null && host.isNotEmpty)
MenuItemButton(
leadingIcon: const Icon(Icons.language),
onPressed: () async {
final tabStates = ref.read(tabStatesProvider);
final sameHostIds = tabStates.entries
.where((e) => e.value.url.host == host)
.map((e) => e.key)
.toList();
if (sameHostIds.isNotEmpty) {
await closeTabsWithConfirmation(context, ref, sameHostIds);
}
},
child: const Text('Close from Same Host'),
),
],
child: IconButton(
onPressed: scope.isPreview
? () {}
: () => _closeTab(context, ref, scope.selectedTabId),
onLongPress: scope.isPreview
? null
: () {
if (menuController.isOpen) {
menuController.close();
} else {
menuController.open();
}
},
icon: const Icon(MdiIcons.tabMinus),
),
);
}
}
class _BookmarkToolbarButton extends HookConsumerWidget {
final ContextualToolbarScope scope;
const _BookmarkToolbarButton({required this.scope});
@override
Widget build(BuildContext context, WidgetRef ref) {
final menuController = useMemoized(MenuController.new);
final tabUrl = scope.tabState?.url;
final bookmarkable = tabUrl != null && !scope.isPreview;
final existingGuids = ref
.watch(
bookmarksRepositoryProvider.select(
(async) => EquatableValue(
bookmarkable
? bookmarkGuidsForUrl(async.value, tabUrl)
: const <String>[],
),
),
)
.value;
final isBookmarked = existingGuids.isNotEmpty;
return MenuAnchor(
controller: menuController,
builder: (context, controller, child) => child!,
menuChildren: [
if (isBookmarked)
MenuItemButton(
leadingIcon: const Icon(MdiIcons.bookmarkRemove),
onPressed: () async {
for (final guid in existingGuids) {
await ref
.read(bookmarksRepositoryProvider.notifier)
.delete(guid);
}
if (context.mounted) {
ui_helper.showInfoMessage(context, 'Bookmark removed');
}
},
child: const Text('Remove Bookmark'),
)
else
MenuItemButton(
leadingIcon: const Icon(MdiIcons.bookmarkPlus),
onPressed: !bookmarkable
? null
: () async {
await ref
.read(bookmarksRepositoryProvider.notifier)
.addBookmark(
parentGuid: BookmarkRoot.mobile.id,
url: tabUrl,
title: scope.tabState!.titleOrAuthority,
);
if (context.mounted) {
ui_helper.showInfoMessage(context, 'Bookmark added');
}
},
child: const Text('Add Bookmark'),
),
],
child: IconButton(
onPressed: scope.isPreview
? () {}
: () async {
await BookmarkListRoute(
entryGuid: BookmarkRoot.root.id,
).push(context);
},
onLongPress: scope.isPreview
? null
: () {
if (menuController.isOpen) {
menuController.close();
} else {
menuController.open();
}
},
icon: const Icon(MdiIcons.bookmarkMultiple),
),
);
}
}
class _BookmarkToggleToolbarButton extends ConsumerWidget {
final ContextualToolbarScope scope;
const _BookmarkToggleToolbarButton({required this.scope});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabUrl = scope.tabState?.url;
final bookmarkable = tabUrl != null && !scope.isPreview;
final existingGuids = ref
.watch(
bookmarksRepositoryProvider.select(
(async) => EquatableValue(
bookmarkable
? bookmarkGuidsForUrl(async.value, tabUrl)
: const <String>[],
),
),
)
.value;
final isBookmarked = existingGuids.isNotEmpty;
return IconButton(
tooltip: isBookmarked ? 'Remove bookmark' : 'Add bookmark',
onPressed: scope.isPreview
? () {}
: !bookmarkable
? null
: () async {
if (isBookmarked) {
for (final guid in existingGuids) {
await ref
.read(bookmarksRepositoryProvider.notifier)
.delete(guid);
}
if (context.mounted) {
ui_helper.showInfoMessage(context, 'Bookmark removed');
}
return;
}
await ref
.read(bookmarksRepositoryProvider.notifier)
.addBookmark(
parentGuid: BookmarkRoot.mobile.id,
url: tabUrl,
title: scope.tabState!.titleOrAuthority,
);
if (context.mounted) {
ui_helper.showInfoMessage(context, 'Bookmark added');
}
},
onLongPress: scope.isPreview
? null
: () async {
await BookmarkListRoute(
entryGuid: BookmarkRoot.root.id,
).push(context);
},
icon: Icon(isBookmarked ? Icons.bookmark : Icons.bookmark_border),
);
}
}
Future<void> _adjustFontSize(
BuildContext context,
WidgetRef ref, {
required bool increase,
}) async {
final settings = ref.read(engineSettingsWithDefaultsProvider);
if (settings.automaticFontSizeAdjustment) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Disable automatic font size in settings to adjust manually',
),
duration: Duration(seconds: 2),
),
);
}
return;
}
final current = settings.fontSizeFactor;
final newValue = increase
? (current + fontSizeStep).clamp(fontSizeMin, fontSizeMax)
: (current - fontSizeStep).clamp(fontSizeMin, fontSizeMax);
final rounded = (newValue * 10).round() / 10;
if (rounded == current) return;
await ref
.read(saveEngineSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith.fontSizeFactor(rounded),
);
}
class _ReaderModeToolbarButton extends ConsumerWidget {
final String? selectedTabId;
const _ReaderModeToolbarButton({required this.selectedTabId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final readerableState = ref.watch(
tabStateProvider(
selectedTabId,
).select((state) => state?.readerableState ?? ReaderableState.$default()),
);
final isReaderLoading = ref.watch(
readerableScreenControllerProvider.select((state) => state.isLoading),
);
return IconButton(
onPressed: isReaderLoading
? null
: () async {
await ref
.read(readerableScreenControllerProvider.notifier)
.toggleReaderView(!readerableState.active);
},
icon: Icon(
readerableState.active ? MdiIcons.bookOpen : MdiIcons.bookOpenOutline,
color: readerableState.active
? Theme.of(context).colorScheme.primary
: null,
),
);
}
}
class _DesktopModeToolbarButton extends ConsumerWidget {
final String? selectedTabId;
const _DesktopModeToolbarButton({required this.selectedTabId});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (selectedTabId == null) {
return const IconButton(
onPressed: null,
icon: Icon(Icons.desktop_windows),
);
}
final desktopEnabled = ref.watch(desktopModeProvider(selectedTabId!));
return IconButton(
onPressed: () {
ref.read(desktopModeProvider(selectedTabId!).notifier).toggle();
},
icon: Icon(
desktopEnabled ? Icons.desktop_windows : Icons.desktop_windows_outlined,
color: desktopEnabled ? Theme.of(context).colorScheme.primary : null,
),
);
}
}
class _TranslateToolbarButton extends ConsumerWidget {
final String? selectedTabId;
const _TranslateToolbarButton({required this.selectedTabId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isTranslated = ref.watch(
tabStateProvider(
selectedTabId,
).select((s) => s?.translationState.isTranslated ?? false),
);
return IconButton(
onPressed: () async {
final tabId = selectedTabId;
if (tabId != null) {
if (isTranslated) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.translateRestore();
} else {
await showTranslationBottomSheet(context, selectedTabId: tabId);
}
}
},
onLongPress: () async {
final tabId = selectedTabId;
if (tabId != null) {
await showTranslationBottomSheet(context, selectedTabId: tabId);
}
},
icon: Icon(
isTranslated ? MdiIcons.translateOff : Icons.translate,
color: isTranslated ? Theme.of(context).colorScheme.primary : null,
),
);
}
}
class _FontToolbarButton extends ConsumerWidget {
final String? selectedTabId;
const _FontToolbarButton({required this.selectedTabId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCustom = ref.watch(
engineSettingsWithDefaultsProvider.select(
(s) => !s.automaticFontSizeAdjustment && s.fontSizeFactor != 1.0,
),
);
return IconButton(
onPressed: () => showFontSizeBottomSheet(context),
icon: Icon(
MdiIcons.formatSize,
color: isCustom ? Theme.of(context).colorScheme.primary : null,
),
);
}
}
@@ -0,0 +1,454 @@
/*
* 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 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.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/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.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/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'
as tab_data;
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class ShareMenuButton extends StatelessWidget {
final String? selectedTabId;
const ShareMenuButton({super.key, required this.selectedTabId});
@override
Widget build(BuildContext context) {
return ShareMenuButtonView(
onPressed: selectedTabId == null
? null
: () async {
await showShareBottomSheet(
context,
selectedTabId: selectedTabId!,
);
},
);
}
}
class ShareMenuButtonView extends StatelessWidget {
const ShareMenuButtonView({super.key, this.onPressed});
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return IconButton(onPressed: onPressed, icon: const Icon(Icons.share));
}
}
class NavigationMenuButton extends StatelessWidget {
final String? selectedTabId;
const NavigationMenuButton({super.key, required this.selectedTabId});
@override
Widget build(BuildContext context) {
return NavigationMenuButtonView(
onTap: () async {
await showBrowserMenuSheet(context);
},
);
}
}
class NavigationMenuButtonView extends StatelessWidget {
const NavigationMenuButtonView({super.key, this.onTap});
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return ToolbarButton(onTap: onTap, child: const Icon(Icons.more_vert));
}
}
class AddTabButton extends HookConsumerWidget {
const AddTabButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabMenuController = useMenuController();
return TabCreationMenu(
controller: tabMenuController,
child: AddTabButtonView(
onPressed: () async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
await SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.effectiveDefaultCreateTabType,
).push(context);
if (context.mounted) {
const BrowserRoute().go(context);
}
},
onLongPress: () {
if (tabMenuController.isOpen) {
tabMenuController.close();
} else {
tabMenuController.open();
}
},
),
);
}
}
class AddTabButtonView extends StatelessWidget {
const AddTabButtonView({super.key, this.onPressed, this.onLongPress});
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onPressed,
icon: const Icon(MdiIcons.tabPlus),
onLongPress: onLongPress,
);
}
}
class CloneTabButton extends HookConsumerWidget {
const CloneTabButton({super.key, required this.selectedTabId});
final String? selectedTabId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabMenuController = useMenuController();
return CloneTabMenu(
controller: tabMenuController,
selectedTabId: selectedTabId,
child: CloneTabButtonView(
onPressed: selectedTabId == null
? null
: () => _cloneCurrentTabMode(context, ref, selectedTabId!),
onLongPress: selectedTabId == null
? null
: () {
if (tabMenuController.isOpen) {
tabMenuController.close();
} else {
tabMenuController.open();
}
},
),
);
}
}
class CloneTabButtonView extends StatelessWidget {
const CloneTabButtonView({super.key, this.onPressed, this.onLongPress});
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onPressed,
onLongPress: onLongPress,
icon: const Icon(MdiIcons.contentDuplicate),
);
}
}
class CloneTabMenu extends HookConsumerWidget {
const CloneTabMenu({
super.key,
required this.child,
required this.controller,
required this.selectedTabId,
});
final Widget child;
final MenuController controller;
final String? selectedTabId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final showIsolatedTabUi = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.showIsolatedTabUi,
),
);
return MenuAnchor(
controller: controller,
builder: (context, controller, child) {
return child!;
},
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(MdiIcons.tab),
onPressed: selectedTabId == null
? null
: () => _cloneTabAsRegular(context, ref, selectedTabId!),
child: const Text('Clone as Regular'),
),
MenuItemButton(
leadingIcon: Icon(
MdiIcons.dominoMask,
color: AppColors.of(context).privateTabPurple,
),
onPressed: selectedTabId == null
? null
: () => _cloneTabAsPrivate(context, ref, selectedTabId!),
child: const Text('Clone as Private'),
),
if (showIsolatedTabUi)
MenuItemButton(
leadingIcon: Icon(
MdiIcons.snowflake,
color: AppColors.of(context).isolatedTabTeal,
),
onPressed: selectedTabId == null
? null
: () => _cloneTabAsIsolated(context, ref, selectedTabId!),
child: const Text('Clone as Isolated'),
),
],
child: child,
);
}
}
class TabsCountButtonView extends StatelessWidget {
const TabsCountButtonView({
super.key,
required this.isActive,
required this.onTap,
this.onLongPress,
this.buttonBuilder,
});
final bool isActive;
final VoidCallback onTap;
final VoidCallback? onLongPress;
final Widget Function(
bool isActive,
VoidCallback onTap,
VoidCallback? onLongPress,
)?
buttonBuilder;
@override
Widget build(BuildContext context) {
return (buttonBuilder != null)
? buttonBuilder!(isActive, onTap, onLongPress)
: TabsActionButton(
isActive: isActive,
onTap: onTap,
onLongPress: onLongPress,
);
}
}
Future<void> _cloneCurrentTabMode(
BuildContext context,
WidgetRef ref,
String selectedTabId,
) async {
final containerData = await ref
.read(tab_data.tabDataRepositoryProvider.notifier)
.getTabContainerData(selectedTabId);
final tabId = await ref
.read(tabRepositoryProvider.notifier)
.duplicateTab(
selectTabId: selectedTabId,
containerData: containerData,
selectTab: false,
);
if (context.mounted) {
final repo = ref.read(tabRepositoryProvider.notifier);
ui_helper.showTabSwitchMessage(
context,
onSwitch: () => repo.selectTab(tabId),
);
}
}
Future<void> _cloneTabAsRegular(
BuildContext context,
WidgetRef ref,
String selectedTabId,
) {
return _cloneTabAsMode(context, ref, selectedTabId, mode: TabMode.regular);
}
Future<void> _cloneTabAsPrivate(
BuildContext context,
WidgetRef ref,
String selectedTabId,
) {
return _cloneTabAsMode(context, ref, selectedTabId, mode: TabMode.private);
}
Future<void> _cloneTabAsIsolated(
BuildContext context,
WidgetRef ref,
String selectedTabId,
) {
return _cloneTabAsMode(
context,
ref,
selectedTabId,
mode: TabMode.newIsolated(),
);
}
Future<void> _cloneTabAsMode(
BuildContext context,
WidgetRef ref,
String selectedTabId, {
required TabMode mode,
}) async {
final tabState = ref.read(tabStateProvider(selectedTabId));
if (tabState == null) return;
final containerData = await ref
.read(tab_data.tabDataRepositoryProvider.notifier)
.getTabContainerData(selectedTabId);
final repo = ref.read(tabRepositoryProvider.notifier);
final tabId = switch (mode) {
RegularTabMode() =>
tabState.tabMode is RegularTabMode
? await repo.duplicateTab(
selectTabId: selectedTabId,
containerData: containerData,
selectTab: false,
)
: await repo.addTab(
tabMode: TabMode.regular,
url: tabState.url,
containerSelection: containerData == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(containerData),
selectTab: false,
),
PrivateTabMode() =>
tabState.tabMode is PrivateTabMode
? await repo.duplicateTab(
selectTabId: selectedTabId,
containerData: containerData,
selectTab: false,
)
: await repo.addTab(
tabMode: TabMode.private,
url: tabState.url,
containerSelection: containerData == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(containerData),
selectTab: false,
),
IsolatedTabMode() => await repo.addTab(
tabMode: TabMode.newIsolated(),
url: tabState.url,
containerSelection: containerData == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(containerData),
selectTab: false,
),
};
if (context.mounted) {
ui_helper.showTabSwitchMessage(
context,
onSwitch: () => repo.selectTab(tabId),
);
}
}
class TabsCountButton extends HookConsumerWidget {
const TabsCountButton({
super.key,
required this.selectedTabId,
required this.displayedSheet,
required this.showLongPressMenu,
});
final String? selectedTabId;
final Sheet? displayedSheet;
final bool showLongPressMenu;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabMenuController = useMenuController();
return TabCreationMenu(
controller: tabMenuController,
child: TabsCountButtonView(
isActive: displayedSheet is ViewTabsSheet,
onTap: () async {
final tabViewBottomSheet = ref
.read(generalSettingsWithDefaultsProvider)
.tabViewBottomSheet;
if (tabViewBottomSheet) {
if (displayedSheet case ViewTabsSheet()) {
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
} else {
ref
.read(bottomSheetControllerProvider.notifier)
.show(ViewTabsSheet());
}
} else {
await const TabViewRoute().push(context);
}
},
onLongPress: showLongPressMenu
? () {
if (tabMenuController.isOpen) {
tabMenuController.close();
} else {
tabMenuController.open();
}
}
: null,
),
);
}
}
@@ -0,0 +1,131 @@
/*
* 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 '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/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_spec.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/services/toolbar_button_resolution.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/models/contextual_toolbar_scope.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/toolbar_button_registry.dart';
class ContextualToolbar extends HookConsumerWidget {
const ContextualToolbar({
super.key,
required this.selectedTabId,
required this.displayedSheet,
});
final String? selectedTabId;
final Sheet? displayedSheet;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
final configs = ref.watch(effectiveToolbarButtonConfigsProvider);
final scope = ContextualToolbarScope(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
tabState: tabState,
isPreview: false,
);
final resolvedButtons = useMemoized(
() => resolveVisibleContextualToolbarButtons(
configs: configs.value,
knownButtonIds: knownToolbarButtonIds,
isPrimaryAvailable: (buttonId) {
final def = toolbarButtonRegistryById[buttonId];
return def?.isPrimaryAvailable?.call(scope, ref) ?? true;
},
),
[configs, scope],
);
final buttons = resolvedButtons
.map((button) => _buildButton(scope, context, ref, button))
.toList();
return ContextualToolbarView(buttons: buttons);
}
Widget _buildButton(
ContextualToolbarScope scope,
BuildContext context,
WidgetRef ref,
ContextualToolbarButtonResolution button,
) {
final def = toolbarButtonRegistryById[button.buttonId];
if (def == null) return const SizedBox.shrink();
final child = def.builder(scope, context, ref);
if (button.isEnabled) {
return child;
}
return Opacity(opacity: 0.38, child: IgnorePointer(child: child));
}
}
class ContextualToolbarView extends StatelessWidget {
const ContextualToolbarView({super.key, required this.buttons});
final List<Widget> buttons;
static const _minButtonWidth = 48.0;
@override
Widget build(BuildContext context) {
if (buttons.isEmpty) return const SizedBox.shrink();
return LayoutBuilder(
builder: (context, constraints) {
final fitsEvenly =
constraints.maxWidth >= _minButtonWidth * buttons.length;
if (fitsEvenly) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: buttons,
);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: buttons
.map(
(button) => SizedBox(
width: _minButtonWidth,
child: Center(child: button),
),
)
.toList(),
),
);
},
);
}
}
@@ -0,0 +1,124 @@
/*
* 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:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:riverpod/experimental/persist.dart';
import 'package:riverpod_annotation/experimental/json_persist.dart';
import 'package:riverpod_annotation/experimental/persist.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/tab_view_filter_options.dart';
import 'package:weblibre/features/user/data/providers.dart';
part 'tab_view_controllers.g.dart';
enum TabsViewMode {
list(MdiIcons.folderTable, 'List'),
grid(MdiIcons.table, 'Grid'),
tree(MdiIcons.familyTree, 'Tree');
final IconData icon;
final String label;
const TabsViewMode(this.icon, this.label);
}
@Riverpod(keepAlive: true)
class TabsViewModeController extends _$TabsViewModeController {
void set(TabsViewMode mode) {
if (mode != state) {
state = mode;
}
}
@override
TabsViewMode build() {
persist(
ref.watch(riverpodDatabaseStorageProvider),
key: 'TabsViewMode',
encode: (state) => jsonEncode([state.name]),
decode: (encoded) {
final name = (jsonDecode(encoded) as List<dynamic>).first as String;
return TabsViewMode.values.firstWhere((e) => e.name == name);
},
);
return stateOrNull ?? TabsViewMode.list;
}
}
@Riverpod(keepAlive: true)
@JsonPersist()
class TabViewFilterController extends _$TabViewFilterController {
void setTabTypeFilter(TabTypeFilter filter) {
state = state.copyWith.tabTypeFilter(filter);
}
void setSortType(TabSortType sort) {
state = state.copyWith.sortType(sort);
}
void setSortPinnedFirst(bool value) {
state = state.copyWith(sortPinnedFirst: value);
}
void setDateRange(DateTimeRange<DateTime>? range) {
// ignore: avoid_redundant_argument_values
state = state.copyWith(dateRange: range, quickInterval: null);
}
void setQuickInterval(TabQuickInterval? interval) {
// ignore: avoid_redundant_argument_values
state = state.copyWith(quickInterval: interval, dateRange: null);
}
void reset() {
state = TabViewFilterOptions.withDefaults();
}
@override
TabViewFilterOptions build() {
persist(
ref.watch(riverpodDatabaseStorageProvider),
key: 'TabViewFilterOptions',
);
return stateOrNull ?? TabViewFilterOptions.withDefaults();
}
}
@Riverpod()
class TabsReorderableController extends _$TabsReorderableController {
void toggle() {
state = !state;
}
void hide() {
if (state) {
state = false;
}
}
@override
bool build() {
return false;
}
}
@@ -0,0 +1,210 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tab_view_controllers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(TabsViewModeController)
final tabsViewModeControllerProvider = TabsViewModeControllerProvider._();
final class TabsViewModeControllerProvider
extends $NotifierProvider<TabsViewModeController, TabsViewMode> {
TabsViewModeControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabsViewModeControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabsViewModeControllerHash();
@$internal
@override
TabsViewModeController create() => TabsViewModeController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(TabsViewMode value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<TabsViewMode>(value),
);
}
}
String _$tabsViewModeControllerHash() =>
r'e013b174218fcad81981f7d939cf161c3b002adc';
abstract class _$TabsViewModeController extends $Notifier<TabsViewMode> {
TabsViewMode build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<TabsViewMode, TabsViewMode>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<TabsViewMode, TabsViewMode>,
TabsViewMode,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(TabViewFilterController)
@JsonPersist()
final tabViewFilterControllerProvider = TabViewFilterControllerProvider._();
@JsonPersist()
final class TabViewFilterControllerProvider
extends $NotifierProvider<TabViewFilterController, TabViewFilterOptions> {
TabViewFilterControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabViewFilterControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabViewFilterControllerHash();
@$internal
@override
TabViewFilterController create() => TabViewFilterController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(TabViewFilterOptions value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<TabViewFilterOptions>(value),
);
}
}
String _$tabViewFilterControllerHash() =>
r'95e8a03d60ebe05e0c5df38f810f951a4fe2ed78';
@JsonPersist()
abstract class _$TabViewFilterControllerBase
extends $Notifier<TabViewFilterOptions> {
TabViewFilterOptions build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<TabViewFilterOptions, TabViewFilterOptions>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<TabViewFilterOptions, TabViewFilterOptions>,
TabViewFilterOptions,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(TabsReorderableController)
final tabsReorderableControllerProvider = TabsReorderableControllerProvider._();
final class TabsReorderableControllerProvider
extends $NotifierProvider<TabsReorderableController, bool> {
TabsReorderableControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabsReorderableControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabsReorderableControllerHash();
@$internal
@override
TabsReorderableController create() => TabsReorderableController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$tabsReorderableControllerHash() =>
r'f169e9dc04055ef611f17ebe121f0f51f6a294cb';
abstract class _$TabsReorderableController extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
// **************************************************************************
// JsonGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
abstract class _$TabViewFilterController extends _$TabViewFilterControllerBase {
/// The default key used by [persist].
String get key {
const resolvedKey = "TabViewFilterController";
return resolvedKey;
}
/// A variant of [persist], for JSON-specific encoding.
///
/// You can override [key] to customize the key used for storage.
PersistResult persist(
FutureOr<Storage<String, String>> storage, {
String? key,
String Function(TabViewFilterOptions state)? encode,
TabViewFilterOptions Function(String encoded)? decode,
StorageOptions options = const StorageOptions(),
}) {
return NotifierPersistX(this).persist<String, String>(
storage,
key: key ?? this.key,
encode: encode ?? $jsonCodex.encode,
decode:
decode ??
(encoded) {
final e = $jsonCodex.decode(encoded);
return TabViewFilterOptions.fromJson(e as Map<String, Object?>);
},
options: options,
);
}
}
@@ -0,0 +1,108 @@
/*
* 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 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'toolbar_visibility.g.dart';
enum ToolbarVisibility { visible, hidden, dismissed }
@Riverpod(keepAlive: true)
class ToolbarVisibilityController extends _$ToolbarVisibilityController {
@override
ToolbarVisibility build(String? tabId) {
// Show toolbar when loading starts
ref.listen(tabStatesProvider.select((tabs) => tabs[tabId]?.isLoading), (
previous,
next,
) {
if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) {
return;
}
if (next == true) {
show();
}
});
// Show toolbar on navigation (history state change)
ref.listen(tabStatesProvider.select((tabs) => tabs[tabId]?.historyState), (
previous,
next,
) {
if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) {
return;
}
if (next != null && previous != null && previous != next) {
show();
}
});
// Force-show when GeckoView requests toolbar expansion
// (e.g. touch on form input)
ref.listen<bool>(
tabStatesProvider.select(
(tabs) => tabs[tabId]?.showToolbarAsExpanded ?? false,
),
(previous, next) {
if (next && previous != next) {
forceShow();
}
},
);
return ToolbarVisibility.visible;
}
/// Hide toolbar via scroll. All guards checked internally.
void requestHide() {
if (state != ToolbarVisibility.visible) return;
final settings = ref.read(generalSettingsWithDefaultsProvider);
if (!settings.autoHideTabBar) return;
final isLoading = ref.read(tabStatesProvider)[tabId]?.isLoading ?? false;
if (isLoading) return;
final viewportService = ref.read(viewportServiceProvider);
if (!viewportService.isBrowserHandlingScrollEnabled) return;
state = ToolbarVisibility.hidden;
}
/// Show toolbar (scroll-up, tab change, loading start, etc).
/// Won't show if manually dismissed — use forceShow() for that.
void show() {
if (state != ToolbarVisibility.hidden) return;
state = ToolbarVisibility.visible;
}
/// Force-show unconditionally + un-dismiss.
void forceShow() {
state = ToolbarVisibility.visible;
}
/// Dismiss toolbar (user swipe). Only affects this tab.
void dismiss() {
state = ToolbarVisibility.dismissed;
}
}
@@ -0,0 +1,111 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'toolbar_visibility.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ToolbarVisibilityController)
final toolbarVisibilityControllerProvider =
ToolbarVisibilityControllerFamily._();
final class ToolbarVisibilityControllerProvider
extends $NotifierProvider<ToolbarVisibilityController, ToolbarVisibility> {
ToolbarVisibilityControllerProvider._({
required ToolbarVisibilityControllerFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'toolbarVisibilityControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$toolbarVisibilityControllerHash();
@override
String toString() {
return r'toolbarVisibilityControllerProvider'
''
'($argument)';
}
@$internal
@override
ToolbarVisibilityController create() => ToolbarVisibilityController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ToolbarVisibility value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ToolbarVisibility>(value),
);
}
@override
bool operator ==(Object other) {
return other is ToolbarVisibilityControllerProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$toolbarVisibilityControllerHash() =>
r'e947508c351d4cbbe8c63289171a6d0a34f1a61a';
final class ToolbarVisibilityControllerFamily extends $Family
with
$ClassFamilyOverride<
ToolbarVisibilityController,
ToolbarVisibility,
ToolbarVisibility,
ToolbarVisibility,
String?
> {
ToolbarVisibilityControllerFamily._()
: super(
retry: null,
name: r'toolbarVisibilityControllerProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: false,
);
ToolbarVisibilityControllerProvider call(String? tabId) =>
ToolbarVisibilityControllerProvider._(argument: tabId, from: this);
@override
String toString() => r'toolbarVisibilityControllerProvider';
}
abstract class _$ToolbarVisibilityController
extends $Notifier<ToolbarVisibility> {
late final _$args = ref.$arg as String?;
String? get tabId => _$args;
ToolbarVisibility build(String? tabId);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<ToolbarVisibility, ToolbarVisibility>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ToolbarVisibility, ToolbarVisibility>,
ToolbarVisibility,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@@ -0,0 +1,54 @@
/*
* 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 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
enum BookmarkAllChoice { fast, detailed }
/// Dialog to select between fast (automatic) or detailed (one-by-one) bookmark import.
Future<BookmarkAllChoice?> showBookmarkAllDialog(BuildContext context) {
return showDialog<BookmarkAllChoice>(
context: context,
builder: (context) => SimpleDialog(
title: const Text('Bookmark All Tabs'),
children: [
ListTile(
leading: const Icon(MdiIcons.fastForward),
title: const Text('Fast'),
subtitle: const Text(
'Automatically add all tabs to a selected folder',
),
onTap: () {
Navigator.of(context).pop(BookmarkAllChoice.fast);
},
),
ListTile(
leading: const Icon(MdiIcons.stepForward),
title: const Text('Detailed'),
subtitle: const Text('Review and edit each bookmark individually'),
onTap: () {
Navigator.of(context).pop(BookmarkAllChoice.detailed);
},
),
],
),
);
}
@@ -0,0 +1,51 @@
/*
* 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 'package:flutter/material.dart';
/// Shows a confirmation dialog before clearing site data.
///
/// Returns true if the user confirms, false otherwise.
Future<bool?> showClearSiteDataDialog(
BuildContext context, {
required String host,
required String formattedTypes,
}) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
icon: const Icon(Icons.warning),
title: const Text('Clear Site Data'),
content: Text(
'This will clear $formattedTypes for $host.\n\n'
'You may need to log in again.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Clear'),
),
],
),
);
}
@@ -0,0 +1,67 @@
/*
* 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 'package:flutter/material.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
/// Dialog to select between extracted or full content for sharing.
/// Shows options for extracted (reader-optimized) vs full (complete) content.
Future<void> showContentSelectionDialog(
BuildContext context, {
required Widget title,
required TabData tabData,
required Future<void> Function(String content, String? fileName)
shareMarkdownAction,
}) async {
await showDialog(
context: context,
builder: (context) => SimpleDialog(
title: title,
children: [
ListTile(
title: const Text('Extracted Content'),
subtitle: const Text(
'Reader-optimized content without navigation and ads',
),
onTap: () async {
Navigator.of(context).pop();
await shareMarkdownAction(
tabData.extractedContentMarkdown!,
tabData.title ?? tabData.url?.authority,
);
},
),
ListTile(
title: const Text('Full Content'),
subtitle: const Text(
'Complete page including all elements and structure',
),
onTap: () async {
Navigator.of(context).pop();
await shareMarkdownAction(
tabData.fullContentMarkdown!,
tabData.title ?? tabData.url?.authority,
);
},
),
],
),
);
}
@@ -0,0 +1,102 @@
/*
* 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 '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/browser/domain/services/browser_data.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
/// Shows a bottom sheet to select and delete browsing data.
Future<void> showDeleteDataDialog(
BuildContext context, {
Set<DeleteBrowsingDataType> initialSettings = const {},
}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => _DeleteDataSheet(initialSettings: initialSettings),
);
}
class _DeleteDataSheet extends HookConsumerWidget {
final Set<DeleteBrowsingDataType> initialSettings;
const _DeleteDataSheet({required this.initialSettings});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selections = useState(initialSettings);
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Delete Browsing Data',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
for (final type in DeleteBrowsingDataType.values)
CheckboxListTile.adaptive(
value: selections.value.contains(type),
controlAffinity: ListTileControlAffinity.leading,
title: Text(type.title),
subtitle: type.description.mapNotNull(
(description) => Text(description),
),
onChanged: (value) {
if (value == true) {
selections.value = {...selections.value, type};
} else {
selections.value = {...selections.value}..remove(type);
}
},
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: selections.value.isEmpty
? null
: () async {
await ref
.read(browserDataServiceProvider.notifier)
.deleteData(selections.value);
if (context.mounted) {
context.pop();
}
},
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
label: const Text('Delete'),
icon: const Icon(Icons.delete_forever),
),
],
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More