performance improvements
This commit is contained in:
@@ -19,26 +19,42 @@
|
|||||||
*/
|
*/
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
/// A wrapper around [Image] that compares by a precomputed byte-hash.
|
/// What makes one decoded image the *same* image as another.
|
||||||
///
|
///
|
||||||
/// Instances are byte-hash deduped via the decoder's LRU, so the same
|
/// Deliberately a record rather than a single combined `int`: [digest] is a
|
||||||
/// `EquatableImage` may be shared across many holders (multiple tabs of
|
/// 64-bit content hash, and folding it together with the decode options via
|
||||||
/// the same site, the icon cache, widget closures). To make that safe,
|
/// `Object.hash` would truncate the whole thing to 30 bits (Dart caps hash
|
||||||
/// the underlying `ui.Image` is disposed via a [Finalizer] when this
|
/// codes at `0x3fffffff`) and leave the result standing in as the identity
|
||||||
/// wrapper becomes unreachable — never on cache eviction or state
|
/// with no exact comparison behind it. As a record, hash codes only pick the
|
||||||
/// transitions, where another holder might still be using it.
|
/// bucket and structural `==` decides equality, so the full digest and the
|
||||||
|
/// exact options both count.
|
||||||
|
typedef ImageIdentity = ({
|
||||||
|
int digest,
|
||||||
|
int? targetWidth,
|
||||||
|
int? targetHeight,
|
||||||
|
bool allowUpscaling,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// A wrapper around [Image] that compares by a precomputed [ImageIdentity].
|
||||||
|
///
|
||||||
|
/// Instances are deduped via the decoder's LRU, so the same `EquatableImage`
|
||||||
|
/// may be shared across many holders (multiple tabs of the same site, the icon
|
||||||
|
/// cache, widget closures). To make that safe, the underlying `ui.Image` is
|
||||||
|
/// disposed via a [Finalizer] when this wrapper becomes unreachable — never on
|
||||||
|
/// cache eviction or state transitions, where another holder might still be
|
||||||
|
/// using it.
|
||||||
class EquatableImage {
|
class EquatableImage {
|
||||||
static final Finalizer<Image> _finalizer = Finalizer<Image>(
|
static final Finalizer<Image> _finalizer = Finalizer<Image>(
|
||||||
(image) => image.dispose(),
|
(image) => image.dispose(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Image? _value;
|
Image? _value;
|
||||||
final int _imageHash;
|
final ImageIdentity _identity;
|
||||||
bool _isDisposed = false;
|
bool _isDisposed = false;
|
||||||
|
|
||||||
EquatableImage(Image value, {required int hash})
|
EquatableImage(Image value, {required ImageIdentity identity})
|
||||||
: _value = value,
|
: _value = value,
|
||||||
_imageHash = hash {
|
_identity = identity {
|
||||||
_finalizer.attach(this, value, detach: this);
|
_finalizer.attach(this, value, detach: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +79,10 @@ class EquatableImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => _imageHash.hashCode;
|
int get hashCode => _identity.hashCode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return other is EquatableImage && other._imageHash == _imageHash;
|
return other is EquatableImage && other._identity == _identity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,15 +23,20 @@ import 'package:nullability/nullability.dart';
|
|||||||
import 'package:weblibre/data/models/web_page_info.dart';
|
import 'package:weblibre/data/models/web_page_info.dart';
|
||||||
import 'package:weblibre/domain/entities/equatable_image.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/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/readerable.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/entities/states/security.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';
|
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||||
|
|
||||||
part 'tab.g.dart';
|
part 'tab.g.dart';
|
||||||
|
|
||||||
|
/// Per-tab state the browser chrome and tab ordering depend on.
|
||||||
|
///
|
||||||
|
/// Deliberately narrow: the whole `Map<String, TabState>` is watched by the
|
||||||
|
/// providers feeding the always-visible quick tab switcher and the grouped tab
|
||||||
|
/// list, so any field added here rebuilds every visible chip whenever it
|
||||||
|
/// changes on *any* tab. High-churn fields (load progress, thumbnails, session
|
||||||
|
/// history, find-in-page results, translation) live in per-tab notifiers in
|
||||||
|
/// `providers/tab_detail_state.dart` instead.
|
||||||
@CopyWith(constructor: '_')
|
@CopyWith(constructor: '_')
|
||||||
class TabState extends WebPageInfo {
|
class TabState extends WebPageInfo {
|
||||||
static final defaultUrl = Uri.parse('about:blank');
|
static final defaultUrl = Uri.parse('about:blank');
|
||||||
@@ -59,10 +64,6 @@ class TabState extends WebPageInfo {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final EquatableImage? thumbnail;
|
|
||||||
|
|
||||||
final int progress;
|
|
||||||
|
|
||||||
final TabMode tabMode;
|
final TabMode tabMode;
|
||||||
String? get isolationContextId => tabMode.isolationContextId;
|
String? get isolationContextId => tabMode.isolationContextId;
|
||||||
|
|
||||||
@@ -70,13 +71,8 @@ class TabState extends WebPageInfo {
|
|||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final bool showToolbarAsExpanded;
|
final bool showToolbarAsExpanded;
|
||||||
|
|
||||||
bool get isFinishedLoading => !isLoading && progress == 100;
|
|
||||||
|
|
||||||
final SecurityState securityInfoState;
|
final SecurityState securityInfoState;
|
||||||
final HistoryState historyState;
|
|
||||||
final ReaderableState readerableState;
|
final ReaderableState readerableState;
|
||||||
final FindResultState findResultState;
|
|
||||||
final TranslationState translationState;
|
|
||||||
|
|
||||||
TabState({
|
TabState({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -85,17 +81,12 @@ class TabState extends WebPageInfo {
|
|||||||
required super.url,
|
required super.url,
|
||||||
required String title,
|
required String title,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.thumbnail,
|
|
||||||
required this.progress,
|
|
||||||
this.tabMode = TabMode.regular,
|
this.tabMode = TabMode.regular,
|
||||||
required this.isFullScreen,
|
required this.isFullScreen,
|
||||||
required this.isLoading,
|
required this.isLoading,
|
||||||
required this.showToolbarAsExpanded,
|
required this.showToolbarAsExpanded,
|
||||||
required this.securityInfoState,
|
required this.securityInfoState,
|
||||||
required this.historyState,
|
|
||||||
required this.readerableState,
|
required this.readerableState,
|
||||||
required this.findResultState,
|
|
||||||
required this.translationState,
|
|
||||||
}) : super(title: title.trim());
|
}) : super(title: title.trim());
|
||||||
|
|
||||||
TabState._({
|
TabState._({
|
||||||
@@ -105,17 +96,12 @@ class TabState extends WebPageInfo {
|
|||||||
required super.url,
|
required super.url,
|
||||||
required super.title,
|
required super.title,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.thumbnail,
|
|
||||||
required this.progress,
|
|
||||||
required this.tabMode,
|
required this.tabMode,
|
||||||
required this.isFullScreen,
|
required this.isFullScreen,
|
||||||
required this.isLoading,
|
required this.isLoading,
|
||||||
required this.showToolbarAsExpanded,
|
required this.showToolbarAsExpanded,
|
||||||
required this.securityInfoState,
|
required this.securityInfoState,
|
||||||
required this.historyState,
|
|
||||||
required this.readerableState,
|
required this.readerableState,
|
||||||
required this.findResultState,
|
|
||||||
required this.translationState,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory TabState.$default(String tabId) => TabState(
|
factory TabState.$default(String tabId) => TabState(
|
||||||
@@ -125,16 +111,11 @@ class TabState extends WebPageInfo {
|
|||||||
url: defaultUrl,
|
url: defaultUrl,
|
||||||
title: "",
|
title: "",
|
||||||
icon: null,
|
icon: null,
|
||||||
thumbnail: null,
|
|
||||||
progress: 0,
|
|
||||||
isFullScreen: false,
|
isFullScreen: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
showToolbarAsExpanded: false,
|
showToolbarAsExpanded: false,
|
||||||
securityInfoState: SecurityState.$default(),
|
securityInfoState: SecurityState.$default(),
|
||||||
historyState: HistoryState.$default(),
|
|
||||||
readerableState: ReaderableState.$default(),
|
readerableState: ReaderableState.$default(),
|
||||||
findResultState: FindResultState.$default(),
|
|
||||||
translationState: TranslationState.$default(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -144,16 +125,11 @@ class TabState extends WebPageInfo {
|
|||||||
parentId,
|
parentId,
|
||||||
contextId,
|
contextId,
|
||||||
icon,
|
icon,
|
||||||
thumbnail,
|
|
||||||
progress,
|
|
||||||
tabMode,
|
tabMode,
|
||||||
isFullScreen,
|
isFullScreen,
|
||||||
isLoading,
|
isLoading,
|
||||||
showToolbarAsExpanded,
|
showToolbarAsExpanded,
|
||||||
securityInfoState,
|
securityInfoState,
|
||||||
historyState,
|
|
||||||
readerableState,
|
readerableState,
|
||||||
findResultState,
|
|
||||||
translationState,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ abstract class _$TabStateCWProxy {
|
|||||||
|
|
||||||
TabState icon(EquatableImage? icon);
|
TabState icon(EquatableImage? icon);
|
||||||
|
|
||||||
TabState thumbnail(EquatableImage? thumbnail);
|
|
||||||
|
|
||||||
TabState progress(int progress);
|
|
||||||
|
|
||||||
TabState tabMode(TabMode tabMode);
|
TabState tabMode(TabMode tabMode);
|
||||||
|
|
||||||
TabState isFullScreen(bool isFullScreen);
|
TabState isFullScreen(bool isFullScreen);
|
||||||
@@ -31,14 +27,8 @@ abstract class _$TabStateCWProxy {
|
|||||||
|
|
||||||
TabState securityInfoState(SecurityState securityInfoState);
|
TabState securityInfoState(SecurityState securityInfoState);
|
||||||
|
|
||||||
TabState historyState(HistoryState historyState);
|
|
||||||
|
|
||||||
TabState readerableState(ReaderableState readerableState);
|
TabState readerableState(ReaderableState readerableState);
|
||||||
|
|
||||||
TabState findResultState(FindResultState findResultState);
|
|
||||||
|
|
||||||
TabState translationState(TranslationState translationState);
|
|
||||||
|
|
||||||
/// Creates a new instance with the provided field values.
|
/// 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)`.
|
/// 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)`.
|
||||||
///
|
///
|
||||||
@@ -52,17 +42,12 @@ abstract class _$TabStateCWProxy {
|
|||||||
Uri url,
|
Uri url,
|
||||||
String? title,
|
String? title,
|
||||||
EquatableImage? icon,
|
EquatableImage? icon,
|
||||||
EquatableImage? thumbnail,
|
|
||||||
int progress,
|
|
||||||
TabMode tabMode,
|
TabMode tabMode,
|
||||||
bool isFullScreen,
|
bool isFullScreen,
|
||||||
bool isLoading,
|
bool isLoading,
|
||||||
bool showToolbarAsExpanded,
|
bool showToolbarAsExpanded,
|
||||||
SecurityState securityInfoState,
|
SecurityState securityInfoState,
|
||||||
HistoryState historyState,
|
|
||||||
ReaderableState readerableState,
|
ReaderableState readerableState,
|
||||||
FindResultState findResultState,
|
|
||||||
TranslationState translationState,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +73,6 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
|
|||||||
@override
|
@override
|
||||||
TabState icon(EquatableImage? icon) => call(icon: icon);
|
TabState icon(EquatableImage? icon) => call(icon: icon);
|
||||||
|
|
||||||
@override
|
|
||||||
TabState thumbnail(EquatableImage? thumbnail) => call(thumbnail: thumbnail);
|
|
||||||
|
|
||||||
@override
|
|
||||||
TabState progress(int progress) => call(progress: progress);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TabState tabMode(TabMode tabMode) => call(tabMode: tabMode);
|
TabState tabMode(TabMode tabMode) => call(tabMode: tabMode);
|
||||||
|
|
||||||
@@ -111,22 +90,10 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
|
|||||||
TabState securityInfoState(SecurityState securityInfoState) =>
|
TabState securityInfoState(SecurityState securityInfoState) =>
|
||||||
call(securityInfoState: securityInfoState);
|
call(securityInfoState: securityInfoState);
|
||||||
|
|
||||||
@override
|
|
||||||
TabState historyState(HistoryState historyState) =>
|
|
||||||
call(historyState: historyState);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TabState readerableState(ReaderableState readerableState) =>
|
TabState readerableState(ReaderableState readerableState) =>
|
||||||
call(readerableState: readerableState);
|
call(readerableState: readerableState);
|
||||||
|
|
||||||
@override
|
|
||||||
TabState findResultState(FindResultState findResultState) =>
|
|
||||||
call(findResultState: findResultState);
|
|
||||||
|
|
||||||
@override
|
|
||||||
TabState translationState(TranslationState translationState) =>
|
|
||||||
call(translationState: translationState);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
/// Creates a new instance with the provided field values.
|
/// 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)`.
|
/// 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)`.
|
||||||
@@ -141,17 +108,12 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
|
|||||||
Object? url = const $CopyWithPlaceholder(),
|
Object? url = const $CopyWithPlaceholder(),
|
||||||
Object? title = const $CopyWithPlaceholder(),
|
Object? title = const $CopyWithPlaceholder(),
|
||||||
Object? icon = const $CopyWithPlaceholder(),
|
Object? icon = const $CopyWithPlaceholder(),
|
||||||
Object? thumbnail = const $CopyWithPlaceholder(),
|
|
||||||
Object? progress = const $CopyWithPlaceholder(),
|
|
||||||
Object? tabMode = const $CopyWithPlaceholder(),
|
Object? tabMode = const $CopyWithPlaceholder(),
|
||||||
Object? isFullScreen = const $CopyWithPlaceholder(),
|
Object? isFullScreen = const $CopyWithPlaceholder(),
|
||||||
Object? isLoading = const $CopyWithPlaceholder(),
|
Object? isLoading = const $CopyWithPlaceholder(),
|
||||||
Object? showToolbarAsExpanded = const $CopyWithPlaceholder(),
|
Object? showToolbarAsExpanded = const $CopyWithPlaceholder(),
|
||||||
Object? securityInfoState = const $CopyWithPlaceholder(),
|
Object? securityInfoState = const $CopyWithPlaceholder(),
|
||||||
Object? historyState = const $CopyWithPlaceholder(),
|
|
||||||
Object? readerableState = const $CopyWithPlaceholder(),
|
Object? readerableState = const $CopyWithPlaceholder(),
|
||||||
Object? findResultState = const $CopyWithPlaceholder(),
|
|
||||||
Object? translationState = const $CopyWithPlaceholder(),
|
|
||||||
}) {
|
}) {
|
||||||
return TabState._(
|
return TabState._(
|
||||||
id: _value.id,
|
id: _value.id,
|
||||||
@@ -175,14 +137,6 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
|
|||||||
? _value.icon
|
? _value.icon
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: icon as EquatableImage?,
|
: 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
|
tabMode: tabMode == const $CopyWithPlaceholder() || tabMode == null
|
||||||
? _value.tabMode
|
? _value.tabMode
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
@@ -208,29 +162,12 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
|
|||||||
? _value.securityInfoState
|
? _value.securityInfoState
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: securityInfoState as SecurityState,
|
: securityInfoState as SecurityState,
|
||||||
historyState:
|
|
||||||
historyState == const $CopyWithPlaceholder() || historyState == null
|
|
||||||
? _value.historyState
|
|
||||||
// ignore: cast_nullable_to_non_nullable
|
|
||||||
: historyState as HistoryState,
|
|
||||||
readerableState:
|
readerableState:
|
||||||
readerableState == const $CopyWithPlaceholder() ||
|
readerableState == const $CopyWithPlaceholder() ||
|
||||||
readerableState == null
|
readerableState == null
|
||||||
? _value.readerableState
|
? _value.readerableState
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: readerableState as ReaderableState,
|
: 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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// High-churn per-tab state, deliberately kept *out* of [TabState].
|
||||||
|
///
|
||||||
|
/// [TabState] is fanned out to the always-visible browser chrome — the quick
|
||||||
|
/// tab switcher chips, the grouped tab list, the contextual toolbar — through
|
||||||
|
/// providers that watch the whole `Map<String, TabState>`. Any field that ticks
|
||||||
|
/// during a page load or on a background timer therefore rebuilt every chip on
|
||||||
|
/// screen, even though no chip renders that field.
|
||||||
|
///
|
||||||
|
/// The fields here are each consumed by exactly one or two widgets, so they
|
||||||
|
/// live in their own keyed notifiers and are read through per-tab selectors.
|
||||||
|
/// A progress tick or a background tab's screenshot now invalidates only the
|
||||||
|
/// widget that actually shows it.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:riverpod/riverpod.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/domain/entities/equatable_image.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/translation.dart';
|
||||||
|
|
||||||
|
part 'tab_detail_state.g.dart';
|
||||||
|
|
||||||
|
/// Load progress (0-100) per tab. Ticks continuously while a page loads.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class TabProgressStates extends _$TabProgressStates {
|
||||||
|
@override
|
||||||
|
Map<String, int> build() => const {};
|
||||||
|
|
||||||
|
void update(String tabId, int progress) {
|
||||||
|
if (state[tabId] == progress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {...state}..[tabId] = progress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
int tabProgress(Ref ref, String? tabId) {
|
||||||
|
if (tabId == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref.watch(tabProgressStatesProvider.select((s) => s[tabId] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and
|
||||||
|
/// consumed only by the tab tray previews.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class TabThumbnails extends _$TabThumbnails {
|
||||||
|
@override
|
||||||
|
Map<String, EquatableImage> build() => const {};
|
||||||
|
|
||||||
|
void update(String tabId, EquatableImage? thumbnail) {
|
||||||
|
if (thumbnail == null) {
|
||||||
|
if (!state.containsKey(tabId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {...state}..remove(tabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state[tabId] == thumbnail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {...state}..[tabId] = thumbnail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
EquatableImage? tabThumbnail(Ref ref, String? tabId) {
|
||||||
|
if (tabId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref.watch(tabThumbnailsProvider.select((s) => s[tabId]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session history (back/forward stack) per tab.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class TabHistoryStates extends _$TabHistoryStates {
|
||||||
|
@override
|
||||||
|
Map<String, HistoryState> build() => const {};
|
||||||
|
|
||||||
|
void update(String tabId, HistoryState history) {
|
||||||
|
if (state[tabId] == history) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {...state}..[tabId] = history;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
HistoryState tabHistoryState(Ref ref, String? tabId) {
|
||||||
|
if (tabId == null) {
|
||||||
|
return HistoryState.$default();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref.watch(
|
||||||
|
tabHistoryStatesProvider.select((s) => s[tabId] ?? HistoryState.$default()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while
|
||||||
|
/// a search is running.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class TabFindResultStates extends _$TabFindResultStates {
|
||||||
|
@override
|
||||||
|
Map<String, FindResultState> build() => const {};
|
||||||
|
|
||||||
|
void update(String tabId, FindResultState result) {
|
||||||
|
if (state[tabId] == result) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {...state}..[tabId] = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
FindResultState resultFor(String tabId) =>
|
||||||
|
state[tabId] ?? FindResultState.$default();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
FindResultState tabFindResultState(Ref ref, String? tabId) {
|
||||||
|
if (tabId == null) {
|
||||||
|
return FindResultState.$default();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref.watch(
|
||||||
|
tabFindResultStatesProvider.select(
|
||||||
|
(s) => s[tabId] ?? FindResultState.$default(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translation progress/result per tab.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class TabTranslationStates extends _$TabTranslationStates {
|
||||||
|
@override
|
||||||
|
Map<String, TranslationState> build() => const {};
|
||||||
|
|
||||||
|
void update(String tabId, TranslationState translation) {
|
||||||
|
if (state[tabId] == translation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {...state}..[tabId] = translation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
TranslationState tabTranslationState(Ref ref, String? tabId) {
|
||||||
|
if (tabId == null) {
|
||||||
|
return TranslationState.$default();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref.watch(
|
||||||
|
tabTranslationStatesProvider.select(
|
||||||
|
(s) => s[tabId] ?? TranslationState.$default(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,727 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'tab_detail_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Load progress (0-100) per tab. Ticks continuously while a page loads.
|
||||||
|
|
||||||
|
@ProviderFor(TabProgressStates)
|
||||||
|
final tabProgressStatesProvider = TabProgressStatesProvider._();
|
||||||
|
|
||||||
|
/// Load progress (0-100) per tab. Ticks continuously while a page loads.
|
||||||
|
final class TabProgressStatesProvider
|
||||||
|
extends $NotifierProvider<TabProgressStates, Map<String, int>> {
|
||||||
|
/// Load progress (0-100) per tab. Ticks continuously while a page loads.
|
||||||
|
TabProgressStatesProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'tabProgressStatesProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$tabProgressStatesHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
TabProgressStates create() => TabProgressStates();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(Map<String, int> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<Map<String, int>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$tabProgressStatesHash() => r'60bb03e33e4322eb2869b90b5cc5802d3cdc225d';
|
||||||
|
|
||||||
|
/// Load progress (0-100) per tab. Ticks continuously while a page loads.
|
||||||
|
|
||||||
|
abstract class _$TabProgressStates extends $Notifier<Map<String, int>> {
|
||||||
|
Map<String, int> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
WhenComplete runBuild() {
|
||||||
|
final ref = this.ref as $Ref<Map<String, int>, Map<String, int>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<Map<String, int>, Map<String, int>>,
|
||||||
|
Map<String, int>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
return element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(tabProgress)
|
||||||
|
final tabProgressProvider = TabProgressFamily._();
|
||||||
|
|
||||||
|
final class TabProgressProvider extends $FunctionalProvider<int, int, int>
|
||||||
|
with $Provider<int> {
|
||||||
|
TabProgressProvider._({
|
||||||
|
required TabProgressFamily super.from,
|
||||||
|
required String? super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'tabProgressProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$tabProgressHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'tabProgressProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<int> $createElement($ProviderPointer pointer) =>
|
||||||
|
$ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int create(Ref ref) {
|
||||||
|
final argument = this.argument as String?;
|
||||||
|
return tabProgress(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(int value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<int>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is TabProgressProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$tabProgressHash() => r'83c4990d96f2160e919ee99882905c09872e8bb7';
|
||||||
|
|
||||||
|
final class TabProgressFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<int, String?> {
|
||||||
|
TabProgressFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'tabProgressProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
TabProgressProvider call(String? tabId) =>
|
||||||
|
TabProgressProvider._(argument: tabId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'tabProgressProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and
|
||||||
|
/// consumed only by the tab tray previews.
|
||||||
|
|
||||||
|
@ProviderFor(TabThumbnails)
|
||||||
|
final tabThumbnailsProvider = TabThumbnailsProvider._();
|
||||||
|
|
||||||
|
/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and
|
||||||
|
/// consumed only by the tab tray previews.
|
||||||
|
final class TabThumbnailsProvider
|
||||||
|
extends $NotifierProvider<TabThumbnails, Map<String, EquatableImage>> {
|
||||||
|
/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and
|
||||||
|
/// consumed only by the tab tray previews.
|
||||||
|
TabThumbnailsProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'tabThumbnailsProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$tabThumbnailsHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
TabThumbnails create() => TabThumbnails();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(Map<String, EquatableImage> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<Map<String, EquatableImage>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$tabThumbnailsHash() => r'16c4bb6edff2e6413100d9ce02c1ae7948740cfe';
|
||||||
|
|
||||||
|
/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and
|
||||||
|
/// consumed only by the tab tray previews.
|
||||||
|
|
||||||
|
abstract class _$TabThumbnails extends $Notifier<Map<String, EquatableImage>> {
|
||||||
|
Map<String, EquatableImage> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
WhenComplete runBuild() {
|
||||||
|
final ref =
|
||||||
|
this.ref
|
||||||
|
as $Ref<Map<String, EquatableImage>, Map<String, EquatableImage>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<
|
||||||
|
Map<String, EquatableImage>,
|
||||||
|
Map<String, EquatableImage>
|
||||||
|
>,
|
||||||
|
Map<String, EquatableImage>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
return element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(tabThumbnail)
|
||||||
|
final tabThumbnailProvider = TabThumbnailFamily._();
|
||||||
|
|
||||||
|
final class TabThumbnailProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<EquatableImage?, EquatableImage?, EquatableImage?>
|
||||||
|
with $Provider<EquatableImage?> {
|
||||||
|
TabThumbnailProvider._({
|
||||||
|
required TabThumbnailFamily super.from,
|
||||||
|
required String? super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'tabThumbnailProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$tabThumbnailHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'tabThumbnailProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<EquatableImage?> $createElement($ProviderPointer pointer) =>
|
||||||
|
$ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
EquatableImage? create(Ref ref) {
|
||||||
|
final argument = this.argument as String?;
|
||||||
|
return tabThumbnail(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(EquatableImage? value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<EquatableImage?>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is TabThumbnailProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$tabThumbnailHash() => r'7c8b591abdb5e8cc914ab8e44247a4bca6bddd15';
|
||||||
|
|
||||||
|
final class TabThumbnailFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<EquatableImage?, String?> {
|
||||||
|
TabThumbnailFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'tabThumbnailProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
TabThumbnailProvider call(String? tabId) =>
|
||||||
|
TabThumbnailProvider._(argument: tabId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'tabThumbnailProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session history (back/forward stack) per tab.
|
||||||
|
|
||||||
|
@ProviderFor(TabHistoryStates)
|
||||||
|
final tabHistoryStatesProvider = TabHistoryStatesProvider._();
|
||||||
|
|
||||||
|
/// Session history (back/forward stack) per tab.
|
||||||
|
final class TabHistoryStatesProvider
|
||||||
|
extends $NotifierProvider<TabHistoryStates, Map<String, HistoryState>> {
|
||||||
|
/// Session history (back/forward stack) per tab.
|
||||||
|
TabHistoryStatesProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'tabHistoryStatesProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$tabHistoryStatesHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
TabHistoryStates create() => TabHistoryStates();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(Map<String, HistoryState> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<Map<String, HistoryState>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$tabHistoryStatesHash() => r'3920410334c4354ca66da605d9bfe85b4853f3bf';
|
||||||
|
|
||||||
|
/// Session history (back/forward stack) per tab.
|
||||||
|
|
||||||
|
abstract class _$TabHistoryStates extends $Notifier<Map<String, HistoryState>> {
|
||||||
|
Map<String, HistoryState> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
WhenComplete runBuild() {
|
||||||
|
final ref =
|
||||||
|
this.ref as $Ref<Map<String, HistoryState>, Map<String, HistoryState>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<Map<String, HistoryState>, Map<String, HistoryState>>,
|
||||||
|
Map<String, HistoryState>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
return element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(tabHistoryState)
|
||||||
|
final tabHistoryStateProvider = TabHistoryStateFamily._();
|
||||||
|
|
||||||
|
final class TabHistoryStateProvider
|
||||||
|
extends $FunctionalProvider<HistoryState, HistoryState, HistoryState>
|
||||||
|
with $Provider<HistoryState> {
|
||||||
|
TabHistoryStateProvider._({
|
||||||
|
required TabHistoryStateFamily super.from,
|
||||||
|
required String? super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'tabHistoryStateProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$tabHistoryStateHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'tabHistoryStateProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<HistoryState> $createElement($ProviderPointer pointer) =>
|
||||||
|
$ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
HistoryState create(Ref ref) {
|
||||||
|
final argument = this.argument as String?;
|
||||||
|
return tabHistoryState(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(HistoryState value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<HistoryState>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is TabHistoryStateProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$tabHistoryStateHash() => r'5bca69fddb6fc2f3db6caf5c84bd1a7f5728e1d9';
|
||||||
|
|
||||||
|
final class TabHistoryStateFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<HistoryState, String?> {
|
||||||
|
TabHistoryStateFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'tabHistoryStateProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
TabHistoryStateProvider call(String? tabId) =>
|
||||||
|
TabHistoryStateProvider._(argument: tabId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'tabHistoryStateProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while
|
||||||
|
/// a search is running.
|
||||||
|
|
||||||
|
@ProviderFor(TabFindResultStates)
|
||||||
|
final tabFindResultStatesProvider = TabFindResultStatesProvider._();
|
||||||
|
|
||||||
|
/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while
|
||||||
|
/// a search is running.
|
||||||
|
final class TabFindResultStatesProvider
|
||||||
|
extends
|
||||||
|
$NotifierProvider<TabFindResultStates, Map<String, FindResultState>> {
|
||||||
|
/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while
|
||||||
|
/// a search is running.
|
||||||
|
TabFindResultStatesProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'tabFindResultStatesProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$tabFindResultStatesHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
TabFindResultStates create() => TabFindResultStates();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(Map<String, FindResultState> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<Map<String, FindResultState>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$tabFindResultStatesHash() =>
|
||||||
|
r'f57dee1658eae789002ac3cff15808cbc96b7883';
|
||||||
|
|
||||||
|
/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while
|
||||||
|
/// a search is running.
|
||||||
|
|
||||||
|
abstract class _$TabFindResultStates
|
||||||
|
extends $Notifier<Map<String, FindResultState>> {
|
||||||
|
Map<String, FindResultState> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
WhenComplete runBuild() {
|
||||||
|
final ref =
|
||||||
|
this.ref
|
||||||
|
as $Ref<Map<String, FindResultState>, Map<String, FindResultState>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<
|
||||||
|
Map<String, FindResultState>,
|
||||||
|
Map<String, FindResultState>
|
||||||
|
>,
|
||||||
|
Map<String, FindResultState>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
return element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(tabFindResultState)
|
||||||
|
final tabFindResultStateProvider = TabFindResultStateFamily._();
|
||||||
|
|
||||||
|
final class TabFindResultStateProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<FindResultState, FindResultState, FindResultState>
|
||||||
|
with $Provider<FindResultState> {
|
||||||
|
TabFindResultStateProvider._({
|
||||||
|
required TabFindResultStateFamily super.from,
|
||||||
|
required String? super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'tabFindResultStateProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$tabFindResultStateHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'tabFindResultStateProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<FindResultState> $createElement($ProviderPointer pointer) =>
|
||||||
|
$ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FindResultState create(Ref ref) {
|
||||||
|
final argument = this.argument as String?;
|
||||||
|
return tabFindResultState(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(FindResultState value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<FindResultState>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is TabFindResultStateProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$tabFindResultStateHash() =>
|
||||||
|
r'0f58040111bb43d830e14ae44569265b9ae804d9';
|
||||||
|
|
||||||
|
final class TabFindResultStateFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<FindResultState, String?> {
|
||||||
|
TabFindResultStateFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'tabFindResultStateProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
TabFindResultStateProvider call(String? tabId) =>
|
||||||
|
TabFindResultStateProvider._(argument: tabId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'tabFindResultStateProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translation progress/result per tab.
|
||||||
|
|
||||||
|
@ProviderFor(TabTranslationStates)
|
||||||
|
final tabTranslationStatesProvider = TabTranslationStatesProvider._();
|
||||||
|
|
||||||
|
/// Translation progress/result per tab.
|
||||||
|
final class TabTranslationStatesProvider
|
||||||
|
extends
|
||||||
|
$NotifierProvider<TabTranslationStates, Map<String, TranslationState>> {
|
||||||
|
/// Translation progress/result per tab.
|
||||||
|
TabTranslationStatesProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'tabTranslationStatesProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$tabTranslationStatesHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
TabTranslationStates create() => TabTranslationStates();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(Map<String, TranslationState> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<Map<String, TranslationState>>(
|
||||||
|
value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$tabTranslationStatesHash() =>
|
||||||
|
r'f04004026dc5fd638319528a55e2f10e0f64c9b3';
|
||||||
|
|
||||||
|
/// Translation progress/result per tab.
|
||||||
|
|
||||||
|
abstract class _$TabTranslationStates
|
||||||
|
extends $Notifier<Map<String, TranslationState>> {
|
||||||
|
Map<String, TranslationState> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
WhenComplete runBuild() {
|
||||||
|
final ref =
|
||||||
|
this.ref
|
||||||
|
as $Ref<
|
||||||
|
Map<String, TranslationState>,
|
||||||
|
Map<String, TranslationState>
|
||||||
|
>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<
|
||||||
|
Map<String, TranslationState>,
|
||||||
|
Map<String, TranslationState>
|
||||||
|
>,
|
||||||
|
Map<String, TranslationState>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
return element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(tabTranslationState)
|
||||||
|
final tabTranslationStateProvider = TabTranslationStateFamily._();
|
||||||
|
|
||||||
|
final class TabTranslationStateProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
TranslationState,
|
||||||
|
TranslationState,
|
||||||
|
TranslationState
|
||||||
|
>
|
||||||
|
with $Provider<TranslationState> {
|
||||||
|
TabTranslationStateProvider._({
|
||||||
|
required TabTranslationStateFamily super.from,
|
||||||
|
required String? super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'tabTranslationStateProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$tabTranslationStateHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'tabTranslationStateProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<TranslationState> $createElement($ProviderPointer pointer) =>
|
||||||
|
$ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
TranslationState create(Ref ref) {
|
||||||
|
final argument = this.argument as String?;
|
||||||
|
return tabTranslationState(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(TranslationState value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<TranslationState>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is TabTranslationStateProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$tabTranslationStateHash() =>
|
||||||
|
r'886365e36167542b2e4dbdbeb97c942a414a752e';
|
||||||
|
|
||||||
|
final class TabTranslationStateFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<TranslationState, String?> {
|
||||||
|
TabTranslationStateFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'tabTranslationStateProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
TabTranslationStateProvider call(String? tabId) =>
|
||||||
|
TabTranslationStateProvider._(argument: tabId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'tabTranslationStateProvider';
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:riverpod/riverpod.dart';
|
import 'package:riverpod/riverpod.dart';
|
||||||
@@ -34,6 +35,7 @@ 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/entities/states/translation.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers.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/selected_tab.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.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/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_mode.dart';
|
||||||
@@ -46,8 +48,33 @@ import 'package:weblibre/features/user/domain/repositories/proxy_routing_setting
|
|||||||
|
|
||||||
part 'tab_state.g.dart';
|
part 'tab_state.g.dart';
|
||||||
|
|
||||||
|
/// Decode width for tab thumbnails. The screenshots arrive at full device
|
||||||
|
/// resolution but are only ever shown in tab-tray previews a few hundred
|
||||||
|
/// logical pixels wide, so decoding them at native size burns main-isolate
|
||||||
|
/// time and GPU memory for detail nobody sees. Only the width is constrained
|
||||||
|
/// so the decoder keeps the aspect ratio; upscaling is disabled so smaller
|
||||||
|
/// sources are left alone.
|
||||||
|
const thumbnailDecodeWidth = 720;
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class TabStates extends _$TabStates {
|
class TabStates extends _$TabStates {
|
||||||
|
/// Replaces the entry for [tabId] — but only when [next] actually differs.
|
||||||
|
///
|
||||||
|
/// Every write here allocates a new map, and `Map` compares by identity, so
|
||||||
|
/// an unconditional assignment notifies *all* whole-map watchers (the quick
|
||||||
|
/// tab switcher, the grouped tab list, the FIFO list) even when nothing
|
||||||
|
/// changed. Gecko re-emits the full content state on every progress tick, so
|
||||||
|
/// without this guard a single page load pushed dozens of no-op rebuilds
|
||||||
|
/// through the entire browser chrome. [TabState] is `FastEquatable`, so the
|
||||||
|
/// comparison is a cached-hash check.
|
||||||
|
void _put(String tabId, TabState next) {
|
||||||
|
if (state[tabId] == next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {...state}..[tabId] = next;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onTabContentStateChange(TabContentState contentState) async {
|
Future<void> _onTabContentStateChange(TabContentState contentState) async {
|
||||||
final current = await patchedState(contentState.id);
|
final current = await patchedState(contentState.id);
|
||||||
|
|
||||||
@@ -82,14 +109,19 @@ class TabStates extends _$TabStates {
|
|||||||
contextId: contentState.contextId,
|
contextId: contentState.contextId,
|
||||||
url: url,
|
url: url,
|
||||||
title: resolvedTitle,
|
title: resolvedTitle,
|
||||||
progress: contentState.progress,
|
|
||||||
tabMode: inferredTabMode,
|
tabMode: inferredTabMode,
|
||||||
isFullScreen: contentState.isFullScreen,
|
isFullScreen: contentState.isFullScreen,
|
||||||
isLoading: contentState.isLoading,
|
isLoading: contentState.isLoading,
|
||||||
showToolbarAsExpanded: contentState.showToolbarAsExpanded,
|
showToolbarAsExpanded: contentState.showToolbarAsExpanded,
|
||||||
);
|
);
|
||||||
|
|
||||||
state = {...state}..[contentState.id] = newState;
|
_put(contentState.id, newState);
|
||||||
|
|
||||||
|
// Progress lives outside [TabState] so a load tick doesn't invalidate the
|
||||||
|
// whole map (and with it every chip in the quick tab switcher).
|
||||||
|
ref
|
||||||
|
.read(tabProgressStatesProvider.notifier)
|
||||||
|
.update(contentState.id, contentState.progress);
|
||||||
|
|
||||||
// Only reconcile DB hierarchy when the engine parent link actually changes.
|
// Only reconcile DB hierarchy when the engine parent link actually changes.
|
||||||
// Content-state events also fire on every progress/title tick, and seeding
|
// Content-state events also fire on every progress/title tick, and seeding
|
||||||
@@ -106,7 +138,7 @@ class TabStates extends _$TabStates {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newState.isFinishedLoading) {
|
if (!contentState.isLoading && contentState.progress == 100) {
|
||||||
ref
|
ref
|
||||||
.read(geckoInferenceRepositoryProvider.notifier)
|
.read(geckoInferenceRepositoryProvider.notifier)
|
||||||
.markInitialLoadComplete();
|
.markInitialLoadComplete();
|
||||||
@@ -138,97 +170,111 @@ class TabStates extends _$TabStates {
|
|||||||
final IconChangeEvent(:tabId, :bytes) = event;
|
final IconChangeEvent(:tabId, :bytes) = event;
|
||||||
|
|
||||||
final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes));
|
final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes));
|
||||||
final current = state[tabId] ?? TabState.$default(tabId);
|
|
||||||
|
|
||||||
state = {...state}..[tabId] = current.copyWith.icon(image);
|
if (!ref.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final current = state[tabId] ?? TabState.$default(tabId);
|
||||||
|
_put(tabId, current.copyWith.icon(image));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onThumbnailChange(ThumbnailEvent event) async {
|
Future<void> _onThumbnailChange(ThumbnailEvent event) async {
|
||||||
final ThumbnailEvent(:tabId, :bytes) = event;
|
final ThumbnailEvent(:tabId, :bytes) = event;
|
||||||
|
|
||||||
final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes));
|
final image = await bytes.mapNotNull(
|
||||||
final current = state[tabId] ?? TabState.$default(tabId);
|
(bytes) => tryDecodeImage(
|
||||||
|
bytes,
|
||||||
|
targetWidth: thumbnailDecodeWidth,
|
||||||
|
allowUpscaling: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
state = {...state}..[tabId] = current.copyWith.thumbnail(image);
|
if (!ref.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(tabThumbnailsProvider.notifier).update(tabId, image);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSecurityInfoStateChange(SecurityInfoEvent event) {
|
void _onSecurityInfoStateChange(SecurityInfoEvent event) {
|
||||||
final SecurityInfoEvent(:tabId, :securityInfo) = event;
|
final SecurityInfoEvent(:tabId, :securityInfo) = event;
|
||||||
|
|
||||||
final current = state[tabId] ?? TabState.$default(tabId);
|
final current = state[tabId] ?? TabState.$default(tabId);
|
||||||
state = {...state}
|
_put(
|
||||||
..[tabId] = current.copyWith.securityInfoState(
|
tabId,
|
||||||
|
current.copyWith.securityInfoState(
|
||||||
SecurityState(
|
SecurityState(
|
||||||
secure: securityInfo.secure,
|
secure: securityInfo.secure,
|
||||||
host: securityInfo.host,
|
host: securityInfo.host,
|
||||||
issuer: securityInfo.issuer,
|
issuer: securityInfo.issuer,
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onHistoryStateChange(HistoryEvent event) {
|
void _onHistoryStateChange(HistoryEvent event) {
|
||||||
final HistoryEvent(:tabId, :history) = event;
|
final HistoryEvent(:tabId, :history) = event;
|
||||||
|
|
||||||
final current = state[tabId] ?? TabState.$default(tabId);
|
ref
|
||||||
state = {...state}
|
.read(tabHistoryStatesProvider.notifier)
|
||||||
..[tabId] = current.copyWith.historyState(
|
.update(
|
||||||
HistoryState(
|
tabId,
|
||||||
items: history.items.nonNulls
|
HistoryState(
|
||||||
.map(
|
items: history.items.nonNulls
|
||||||
(item) =>
|
.map(
|
||||||
HistoryItem(url: Uri.parse(item.url), title: item.title),
|
(item) =>
|
||||||
)
|
HistoryItem(url: Uri.parse(item.url), title: item.title),
|
||||||
.toList(),
|
)
|
||||||
currentIndex: history.currentIndex,
|
.toList(),
|
||||||
canGoBack: history.canGoBack,
|
currentIndex: history.currentIndex,
|
||||||
canGoForward: history.canGoForward,
|
canGoBack: history.canGoBack,
|
||||||
),
|
canGoForward: history.canGoForward,
|
||||||
);
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onReaderableStateChange(ReaderableEvent event) {
|
void _onReaderableStateChange(ReaderableEvent event) {
|
||||||
final ReaderableEvent(:tabId, :readerable) = event;
|
final ReaderableEvent(:tabId, :readerable) = event;
|
||||||
|
|
||||||
final current = state[tabId] ?? TabState.$default(tabId);
|
final current = state[tabId] ?? TabState.$default(tabId);
|
||||||
state = {...state}
|
_put(
|
||||||
..[tabId] = current.copyWith.readerableState(
|
tabId,
|
||||||
|
current.copyWith.readerableState(
|
||||||
ReaderableState(
|
ReaderableState(
|
||||||
readerable: readerable.readerable,
|
readerable: readerable.readerable,
|
||||||
active: readerable.active,
|
active: readerable.active,
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTabTranslationStateChange(TabTranslationEvent event) {
|
void _onTabTranslationStateChange(TabTranslationEvent event) {
|
||||||
final TabTranslationEvent(:tabId, :state) = event;
|
final TabTranslationEvent(:tabId, :state) = event;
|
||||||
|
|
||||||
final current = this.state[tabId] ?? TabState.$default(tabId);
|
ref
|
||||||
this.state = {...this.state}
|
.read(tabTranslationStatesProvider.notifier)
|
||||||
..[tabId] = current.copyWith.translationState(
|
.update(tabId, TranslationState.fromData(state));
|
||||||
TranslationState.fromData(state),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onFindResultsChange(FindResultsEvent event) {
|
void _onFindResultsChange(FindResultsEvent event) {
|
||||||
final FindResultsEvent(:tabId, :results) = event;
|
final FindResultsEvent(:tabId, :results) = event;
|
||||||
final current = state[tabId] ?? TabState.$default(tabId);
|
final findResults = ref.read(tabFindResultStatesProvider.notifier);
|
||||||
|
|
||||||
if (results.isNotEmpty) {
|
if (results.isNotEmpty) {
|
||||||
final result = results.last;
|
final result = results.last;
|
||||||
|
|
||||||
state = {...state}
|
findResults.update(
|
||||||
..[tabId] = current.copyWith.findResultState(
|
tabId,
|
||||||
FindResultState(
|
FindResultState(
|
||||||
lastSearchText: ref.read(findInPageRepositoryProvider(tabId)),
|
lastSearchText: ref.read(findInPageRepositoryProvider(tabId)),
|
||||||
activeMatchOrdinal: result.activeMatchOrdinal,
|
activeMatchOrdinal: result.activeMatchOrdinal,
|
||||||
numberOfMatches: result.numberOfMatches,
|
numberOfMatches: result.numberOfMatches,
|
||||||
isDoneCounting: result.isDoneCounting,
|
isDoneCounting: result.isDoneCounting,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (current.findResultState.hasMatches) {
|
} else if (findResults.resultFor(tabId).hasMatches) {
|
||||||
state = {
|
findResults.update(tabId, FindResultState.$default());
|
||||||
...state,
|
|
||||||
}..[tabId] = current.copyWith.findResultState(FindResultState.$default());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,8 +355,15 @@ class TabStates extends _$TabStates {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
// Gecko emits match counters at ~40/s while a find is running. 25ms let
|
||||||
|
// essentially every one of those through; 100ms is still well under the
|
||||||
|
// threshold where the counter feels laggy. Debounced *per tab* so a
|
||||||
|
// background tab still counting can't starve the foreground one.
|
||||||
eventService.findResultsEvent
|
eventService.findResultsEvent
|
||||||
.debounceTime(const Duration(milliseconds: 25))
|
.groupBy((event) => event.tabId)
|
||||||
|
.flatMap(
|
||||||
|
(group) => group.debounceTime(const Duration(milliseconds: 100)),
|
||||||
|
)
|
||||||
.listen(
|
.listen(
|
||||||
(event) {
|
(event) {
|
||||||
_onFindResultsChange(event);
|
_onFindResultsChange(event);
|
||||||
@@ -381,6 +434,42 @@ TabState? tabState(Ref ref, String? tabId) {
|
|||||||
return ref.watch(tabStatesProvider.select((tabs) => tabs[tabId]));
|
return ref.watch(tabStatesProvider.select((tabs) => tabs[tabId]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The only fields of [TabState] that tab filtering/sorting depends on.
|
||||||
|
class TabSortKeys with FastEquatable {
|
||||||
|
final TabMode tabMode;
|
||||||
|
final String titleOrAuthority;
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
TabSortKeys({
|
||||||
|
required this.tabMode,
|
||||||
|
required this.titleOrAuthority,
|
||||||
|
required this.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [tabMode, titleOrAuthority, url];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Projection of [tabStatesProvider] for consumers that order or filter tabs
|
||||||
|
/// but render nothing from the state itself. Watching this instead of the full
|
||||||
|
/// map keeps the (expensive) grouping/sorting passes off the path of every
|
||||||
|
/// icon, security-info and readerable event.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
EquatableValue<Map<String, TabSortKeys>> tabSortKeys(Ref ref) {
|
||||||
|
return ref.watch(
|
||||||
|
tabStatesProvider.select(
|
||||||
|
(states) => EquatableValue({
|
||||||
|
for (final MapEntry(:key, :value) in states.entries)
|
||||||
|
key: TabSortKeys(
|
||||||
|
tabMode: value.tabMode,
|
||||||
|
titleOrAuthority: value.titleOrAuthority,
|
||||||
|
url: value.url.toString(),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Riverpod()
|
@Riverpod()
|
||||||
Future<TabState> tabStateWithFallback(Ref ref, String tabId) async {
|
Future<TabState> tabStateWithFallback(Ref ref, String tabId) async {
|
||||||
final state = ref.watch(tabStateProvider(tabId));
|
final state = ref.watch(tabStateProvider(tabId));
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ final class TabStatesProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$tabStatesHash() => r'04b7f1ea704575e368a0f1186fc1c58f317620b1';
|
String _$tabStatesHash() => r'1ae4e941890116eabf1803809014b3e575c33e73';
|
||||||
|
|
||||||
abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
|
abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
|
||||||
Map<String, TabState> build();
|
Map<String, TabState> build();
|
||||||
@@ -138,6 +138,68 @@ final class TabStateFamily extends $Family
|
|||||||
String toString() => r'tabStateProvider';
|
String toString() => r'tabStateProvider';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Projection of [tabStatesProvider] for consumers that order or filter tabs
|
||||||
|
/// but render nothing from the state itself. Watching this instead of the full
|
||||||
|
/// map keeps the (expensive) grouping/sorting passes off the path of every
|
||||||
|
/// icon, security-info and readerable event.
|
||||||
|
|
||||||
|
@ProviderFor(tabSortKeys)
|
||||||
|
final tabSortKeysProvider = TabSortKeysProvider._();
|
||||||
|
|
||||||
|
/// Projection of [tabStatesProvider] for consumers that order or filter tabs
|
||||||
|
/// but render nothing from the state itself. Watching this instead of the full
|
||||||
|
/// map keeps the (expensive) grouping/sorting passes off the path of every
|
||||||
|
/// icon, security-info and readerable event.
|
||||||
|
|
||||||
|
final class TabSortKeysProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
EquatableValue<Map<String, TabSortKeys>>,
|
||||||
|
EquatableValue<Map<String, TabSortKeys>>,
|
||||||
|
EquatableValue<Map<String, TabSortKeys>>
|
||||||
|
>
|
||||||
|
with $Provider<EquatableValue<Map<String, TabSortKeys>>> {
|
||||||
|
/// Projection of [tabStatesProvider] for consumers that order or filter tabs
|
||||||
|
/// but render nothing from the state itself. Watching this instead of the full
|
||||||
|
/// map keeps the (expensive) grouping/sorting passes off the path of every
|
||||||
|
/// icon, security-info and readerable event.
|
||||||
|
TabSortKeysProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'tabSortKeysProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$tabSortKeysHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<EquatableValue<Map<String, TabSortKeys>>> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
EquatableValue<Map<String, TabSortKeys>> create(Ref ref) {
|
||||||
|
return tabSortKeys(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(EquatableValue<Map<String, TabSortKeys>> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride:
|
||||||
|
$SyncValueProvider<EquatableValue<Map<String, TabSortKeys>>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$tabSortKeysHash() => r'7bf660099d006b6df9846594d2a5d78a32fa3bdb';
|
||||||
|
|
||||||
@ProviderFor(tabStateWithFallback)
|
@ProviderFor(tabStateWithFallback)
|
||||||
final tabStateWithFallbackProvider = TabStateWithFallbackFamily._();
|
final tabStateWithFallbackProvider = TabStateWithFallbackFamily._();
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import 'package:weblibre/features/geckoview/domain/providers.dart';
|
|||||||
import 'package:weblibre/features/geckoview/domain/providers/pending_tab_selection.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/pending_tab_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_list.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/domain/providers/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart';
|
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart';
|
||||||
@@ -957,9 +958,15 @@ class TabRepository extends _$TabRepository {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final historyIsEmpty =
|
||||||
|
ref
|
||||||
|
.read(tabHistoryStatesProvider)[currentTabState.id]
|
||||||
|
?.items
|
||||||
|
.isEmpty ??
|
||||||
|
true;
|
||||||
|
|
||||||
final tabIsEmpty =
|
final tabIsEmpty =
|
||||||
currentTabState.url == TabState.defaultUrl &&
|
currentTabState.url == TabState.defaultUrl && historyIsEmpty;
|
||||||
currentTabState.historyState.items.isEmpty;
|
|
||||||
|
|
||||||
if (event.blocked || tabIsEmpty) {
|
if (event.blocked || tabIsEmpty) {
|
||||||
final newTabId = await addTab(
|
final newTabId = await addTab(
|
||||||
@@ -976,7 +983,7 @@ class TabRepository extends _$TabRepository {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentTabState.historyState.items.isEmpty) {
|
if (historyIsEmpty) {
|
||||||
await closeTab(currentTabState.id);
|
await closeTab(currentTabState.id);
|
||||||
if (!ref.mounted) {
|
if (!ref.mounted) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$tabRepositoryHash() => r'e80c972b0d1a328c170354c123d2730028a774b2';
|
String _$tabRepositoryHash() => r'8abe7686d937434e2970675c143f3891ec34cce2';
|
||||||
|
|
||||||
abstract class _$TabRepository extends $Notifier<void> {
|
abstract class _$TabRepository extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
@@ -881,17 +881,14 @@ EquatableValue<List<TabPreview>> filteredTabPreviews(
|
|||||||
return EquatableValue([]);
|
return EquatableValue([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
final sandboxCaptureMap =
|
final sandboxSourceUris = ref.watch(sandboxSourceUrisProvider).value;
|
||||||
ref.watch(sandboxCaptureMapProvider).value ?? const {};
|
|
||||||
|
|
||||||
return EquatableValue(
|
return EquatableValue(
|
||||||
tabSearchResults.results
|
tabSearchResults.results
|
||||||
.where((tab) => availableTabStates.value.containsKey(tab.id))
|
.where((tab) => availableTabStates.value.containsKey(tab.id))
|
||||||
.map((tab) {
|
.map((tab) {
|
||||||
final tabState = availableTabStates.value[tab.id]!;
|
final tabState = availableTabStates.value[tab.id]!;
|
||||||
final sandboxSourceUri = parseSandboxSource(
|
final sandboxSourceUri = sandboxSourceUris[tab.id];
|
||||||
sandboxCaptureMap[tab.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
return TabPreview(
|
return TabPreview(
|
||||||
id: tab.id,
|
id: tab.id,
|
||||||
@@ -933,7 +930,12 @@ EquatableValue<List<TabListItemEntity>> groupedTabListItems(
|
|||||||
}
|
}
|
||||||
|
|
||||||
final tabList = ref.watch(tabListProvider);
|
final tabList = ref.watch(tabListProvider);
|
||||||
final tabStates = ref.watch(tabStatesProvider);
|
// Narrow projection instead of the whole `Map<String, TabState>`: this
|
||||||
|
// provider does a graph walk plus several sorts, and it feeds the always
|
||||||
|
// visible quick tab switcher's depth map. It only reads the tab mode (for
|
||||||
|
// filtering) and the title/url (for sorting) — none of which change more
|
||||||
|
// often than once per navigation.
|
||||||
|
final tabSortKeys = ref.watch(tabSortKeysProvider).value;
|
||||||
final filterOptions = ref.watch(tabViewFilterControllerProvider);
|
final filterOptions = ref.watch(tabViewFilterControllerProvider);
|
||||||
final pinnedTabIds = ref.watch(
|
final pinnedTabIds = ref.watch(
|
||||||
watchPinnedTabIdsProvider.select(
|
watchPinnedTabIdsProvider.select(
|
||||||
@@ -963,7 +965,7 @@ EquatableValue<List<TabListItemEntity>> groupedTabListItems(
|
|||||||
.where((row) => tabList.value.contains(row.id))
|
.where((row) => tabList.value.contains(row.id))
|
||||||
.where(
|
.where(
|
||||||
(row) => filterOptions.matchesTab(
|
(row) => filterOptions.matchesTab(
|
||||||
tabStates[row.id]?.tabMode,
|
tabSortKeys[row.id]?.tabMode,
|
||||||
tabTimestamps?[row.id],
|
tabTimestamps?[row.id],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1032,7 +1034,7 @@ EquatableValue<List<TabListItemEntity>> groupedTabListItems(
|
|||||||
for (final entry in byRoot.entries) {
|
for (final entry in byRoot.entries) {
|
||||||
final rootMember = entry.value.firstWhere((r) => r.row.id == entry.key);
|
final rootMember = entry.value.firstWhere((r) => r.row.id == entry.key);
|
||||||
final root = rootMember.row;
|
final root = rootMember.row;
|
||||||
final state = tabStates[root.id];
|
final sortKeys = tabSortKeys[root.id];
|
||||||
final timestamp = tabTimestamps?[root.id];
|
final timestamp = tabTimestamps?[root.id];
|
||||||
groupRecords.add(
|
groupRecords.add(
|
||||||
_TabGroupRecord(
|
_TabGroupRecord(
|
||||||
@@ -1043,10 +1045,10 @@ EquatableValue<List<TabListItemEntity>> groupedTabListItems(
|
|||||||
isPinned: pinnedTabIds.contains(root.id),
|
isPinned: pinnedTabIds.contains(root.id),
|
||||||
titleKey:
|
titleKey:
|
||||||
sortField == SortField.titleAsc || sortField == SortField.titleDesc
|
sortField == SortField.titleAsc || sortField == SortField.titleDesc
|
||||||
? (state?.titleOrAuthority ?? '').toLowerCase()
|
? (sortKeys?.titleOrAuthority ?? '').toLowerCase()
|
||||||
: null,
|
: null,
|
||||||
urlKey: sortField == SortField.urlAsc || sortField == SortField.urlDesc
|
urlKey: sortField == SortField.urlAsc || sortField == SortField.urlDesc
|
||||||
? (state?.url.toString() ?? '')
|
? (sortKeys?.url ?? '')
|
||||||
: null,
|
: null,
|
||||||
dateKey:
|
dateKey:
|
||||||
sortField == SortField.dateAsc || sortField == SortField.dateDesc
|
sortField == SortField.dateAsc || sortField == SortField.dateDesc
|
||||||
|
|||||||
@@ -1092,7 +1092,7 @@ final class FilteredTabPreviewsProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$filteredTabPreviewsHash() =>
|
String _$filteredTabPreviewsHash() =>
|
||||||
r'074df0d2000ae325fbd1db775abe4836491b32dd';
|
r'e291cdb7848c9607d388f8fdfb640f25265fca03';
|
||||||
|
|
||||||
final class FilteredTabPreviewsFamily extends $Family
|
final class FilteredTabPreviewsFamily extends $Family
|
||||||
with
|
with
|
||||||
@@ -1212,7 +1212,7 @@ final class GroupedTabListItemsProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$groupedTabListItemsHash() =>
|
String _$groupedTabListItemsHash() =>
|
||||||
r'dbb510f22c00858fcbadfb94547d46841221fca4';
|
r'd7158c50ca34014d63044e7fb52a7197245c59ad';
|
||||||
|
|
||||||
/// Grouped flat-list rendering for the list and grid views.
|
/// Grouped flat-list rendering for the list and grid views.
|
||||||
///
|
///
|
||||||
|
|||||||
+10
-1
@@ -18,6 +18,7 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'package:fast_equatable/fast_equatable.dart';
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/entities/states/history.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
|
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_config_location.dart';
|
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_config_location.dart';
|
||||||
@@ -26,6 +27,12 @@ class ContextualToolbarScope with FastEquatable {
|
|||||||
final String? selectedTabId;
|
final String? selectedTabId;
|
||||||
final Sheet? displayedSheet;
|
final Sheet? displayedSheet;
|
||||||
final TabState? tabState;
|
final TabState? tabState;
|
||||||
|
|
||||||
|
/// Back/forward availability. Lives outside [TabState] (see
|
||||||
|
/// `providers/tab_detail_state.dart`) but the navigation buttons need it, so
|
||||||
|
/// the toolbar resolves it once and passes it down with the scope.
|
||||||
|
final HistoryState historyState;
|
||||||
|
|
||||||
final bool isPreview;
|
final bool isPreview;
|
||||||
|
|
||||||
/// Which configuration set this scope renders for, so shared registry
|
/// Which configuration set this scope renders for, so shared registry
|
||||||
@@ -38,14 +45,16 @@ class ContextualToolbarScope with FastEquatable {
|
|||||||
required this.displayedSheet,
|
required this.displayedSheet,
|
||||||
required this.tabState,
|
required this.tabState,
|
||||||
required this.isPreview,
|
required this.isPreview,
|
||||||
|
HistoryState? historyState,
|
||||||
this.location = ToolbarConfigLocation.contextual,
|
this.location = ToolbarConfigLocation.contextual,
|
||||||
});
|
}) : historyState = historyState ?? HistoryState.$default();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get hashParameters => [
|
List<Object?> get hashParameters => [
|
||||||
selectedTabId,
|
selectedTabId,
|
||||||
displayedSheet,
|
displayedSheet,
|
||||||
tabState,
|
tabState,
|
||||||
|
historyState,
|
||||||
isPreview,
|
isPreview,
|
||||||
location,
|
location,
|
||||||
];
|
];
|
||||||
|
|||||||
+4
-6
@@ -26,6 +26,7 @@ import 'package:weblibre/core/routing/routes.dart';
|
|||||||
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.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.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
|
||||||
@@ -104,7 +105,7 @@ final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
|
|||||||
label: 'Back',
|
label: 'Back',
|
||||||
icon: Icons.arrow_back,
|
icon: Icons.arrow_back,
|
||||||
isPrimaryAvailable: (scope, ref) {
|
isPrimaryAvailable: (scope, ref) {
|
||||||
final canGoBack = scope.tabState?.historyState.canGoBack == true;
|
final canGoBack = scope.historyState.canGoBack;
|
||||||
final isLoading = scope.tabState?.isLoading == true;
|
final isLoading = scope.tabState?.isLoading == true;
|
||||||
// The back button only doubles as a stop-loading control when no
|
// The back button only doubles as a stop-loading control when no
|
||||||
// dedicated reload button is present to take over that role.
|
// dedicated reload button is present to take over that role.
|
||||||
@@ -132,8 +133,7 @@ final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
|
|||||||
spec: forwardToolbarButtonSpec,
|
spec: forwardToolbarButtonSpec,
|
||||||
label: 'Forward',
|
label: 'Forward',
|
||||||
icon: Icons.arrow_forward,
|
icon: Icons.arrow_forward,
|
||||||
isPrimaryAvailable: (scope, ref) =>
|
isPrimaryAvailable: (scope, ref) => scope.historyState.canGoForward,
|
||||||
scope.tabState?.historyState.canGoForward == true,
|
|
||||||
longPressActions: ['History Menu (Forward pages)'],
|
longPressActions: ['History Menu (Forward pages)'],
|
||||||
builder: (scope, context, ref) {
|
builder: (scope, context, ref) {
|
||||||
if (scope.isPreview) {
|
if (scope.isPreview) {
|
||||||
@@ -1026,9 +1026,7 @@ class _TranslateToolbarButton extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isTranslated = ref.watch(
|
final isTranslated = ref.watch(
|
||||||
tabStateProvider(
|
tabTranslationStateProvider(selectedTabId).select((s) => s.isTranslated),
|
||||||
selectedTabId,
|
|
||||||
).select((s) => s?.translationState.isTranslated ?? false),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return IconButton(
|
return IconButton(
|
||||||
|
|||||||
+3
@@ -21,6 +21,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/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/data/providers/toolbar_button_configs.dart';
|
||||||
@@ -48,6 +49,7 @@ class ContextualToolbar extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||||
|
final historyState = ref.watch(tabHistoryStateProvider(selectedTabId));
|
||||||
final configs = ref.watch(
|
final configs = ref.watch(
|
||||||
effectiveToolbarButtonConfigsProvider(ToolbarConfigLocation.contextual),
|
effectiveToolbarButtonConfigsProvider(ToolbarConfigLocation.contextual),
|
||||||
);
|
);
|
||||||
@@ -56,6 +58,7 @@ class ContextualToolbar extends HookConsumerWidget {
|
|||||||
selectedTabId: selectedTabId,
|
selectedTabId: selectedTabId,
|
||||||
displayedSheet: displayedSheet,
|
displayedSheet: displayedSheet,
|
||||||
tabState: tabState,
|
tabState: tabState,
|
||||||
|
historyState: historyState,
|
||||||
isPreview: false,
|
isPreview: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+3
@@ -21,6 +21,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/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/data/providers/toolbar_button_configs.dart';
|
||||||
@@ -62,6 +63,7 @@ class QuickSwitcherButtonRow extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||||
|
final historyState = ref.watch(tabHistoryStateProvider(selectedTabId));
|
||||||
final configs = ref.watch(
|
final configs = ref.watch(
|
||||||
effectiveToolbarButtonConfigsProvider(
|
effectiveToolbarButtonConfigsProvider(
|
||||||
ToolbarConfigLocation.quickSwitcher,
|
ToolbarConfigLocation.quickSwitcher,
|
||||||
@@ -72,6 +74,7 @@ class QuickSwitcherButtonRow extends HookConsumerWidget {
|
|||||||
selectedTabId: selectedTabId,
|
selectedTabId: selectedTabId,
|
||||||
displayedSheet: displayedSheet,
|
displayedSheet: displayedSheet,
|
||||||
tabState: tabState,
|
tabState: tabState,
|
||||||
|
historyState: historyState,
|
||||||
isPreview: false,
|
isPreview: false,
|
||||||
location: ToolbarConfigLocation.quickSwitcher,
|
location: ToolbarConfigLocation.quickSwitcher,
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-11
@@ -20,6 +20,7 @@
|
|||||||
import 'package:riverpod/riverpod.dart';
|
import 'package:riverpod/riverpod.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
|
|
||||||
@@ -45,17 +46,17 @@ class ToolbarVisibilityController extends _$ToolbarVisibilityController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Show toolbar on navigation (history state change)
|
// Show toolbar on navigation (history state change)
|
||||||
ref.listen(tabStatesProvider.select((tabs) => tabs[tabId]?.historyState), (
|
ref.listen(
|
||||||
previous,
|
tabHistoryStatesProvider.select((histories) => histories[tabId]),
|
||||||
next,
|
(previous, next) {
|
||||||
) {
|
if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) {
|
||||||
if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (next != null && previous != null && previous != next) {
|
||||||
if (next != null && previous != null && previous != next) {
|
show();
|
||||||
show();
|
}
|
||||||
}
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
// Force-show when GeckoView requests toolbar expansion
|
// Force-show when GeckoView requests toolbar expansion
|
||||||
// (e.g. touch on form input)
|
// (e.g. touch on form input)
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@ final class ToolbarVisibilityControllerProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$toolbarVisibilityControllerHash() =>
|
String _$toolbarVisibilityControllerHash() =>
|
||||||
r'e947508c351d4cbbe8c63289171a6d0a34f1a61a';
|
r'9d88ca83964d84acfb23f400d02ae277d6866b3f';
|
||||||
|
|
||||||
final class ToolbarVisibilityControllerFamily extends $Family
|
final class ToolbarVisibilityControllerFamily extends $Family
|
||||||
with
|
with
|
||||||
|
|||||||
+138
-47
@@ -35,6 +35,7 @@ import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart';
|
|||||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers.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/selected_tab.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/tab_state.dart';
|
||||||
@@ -492,9 +493,14 @@ class _BrowserContentPositioned extends ConsumerWidget {
|
|||||||
final toolbarState = ref.watch(
|
final toolbarState = ref.watch(
|
||||||
toolbarVisibilityControllerProvider(selectedTabId),
|
toolbarVisibilityControllerProvider(selectedTabId),
|
||||||
);
|
);
|
||||||
|
// Deliberately does NOT include `sheetDisplayed` (unlike the layers that
|
||||||
|
// render chrome above the page). A sheet is painted over the browser, so
|
||||||
|
// letting it force the toolbar "visible" here would move the Positioned
|
||||||
|
// that wraps the platform view — a native re-layout, and under hybrid
|
||||||
|
// composition a surface recreation, in the same frame as the sheet's
|
||||||
|
// slide-in. The geometry the page sees stays frozen while a sheet is up.
|
||||||
final toolbarVisible =
|
final toolbarVisible =
|
||||||
sheetDisplayed ||
|
!tabInFullScreen && toolbarState == ToolbarVisibility.visible;
|
||||||
(!tabInFullScreen && toolbarState == ToolbarVisibility.visible);
|
|
||||||
|
|
||||||
// When auto-hide is disabled, constrain browser above toolbar
|
// When auto-hide is disabled, constrain browser above toolbar
|
||||||
// (unless toolbar is manually dismissed via swipe gesture).
|
// (unless toolbar is manually dismissed via swipe gesture).
|
||||||
@@ -527,11 +533,14 @@ class _BrowserContentPositioned extends ConsumerWidget {
|
|||||||
// hidden state is still handled by GeckoView's dynamic
|
// hidden state is still handled by GeckoView's dynamic
|
||||||
// toolbar/clipping logic. On the rail there is never a bottom
|
// toolbar/clipping logic. On the rail there is never a bottom
|
||||||
// bar, so the browser always needs the bottom safe inset.
|
// bar, so the browser always needs the bottom safe inset.
|
||||||
|
//
|
||||||
|
// `sheetDisplayed` is likewise excluded here: toggling the safe-area
|
||||||
|
// padding on sheet open/close would resize the platform view for content
|
||||||
|
// the sheet covers anyway.
|
||||||
final applyBottomSafeArea = isRail
|
final applyBottomSafeArea = isRail
|
||||||
? (!tabInFullScreen && !isSmallWebActive && !sheetDisplayed)
|
? (!tabInFullScreen && !isSmallWebActive)
|
||||||
: (!tabInFullScreen &&
|
: (!tabInFullScreen &&
|
||||||
!isSmallWebActive &&
|
!isSmallWebActive &&
|
||||||
!sheetDisplayed &&
|
|
||||||
bottomOffset == 0 &&
|
bottomOffset == 0 &&
|
||||||
toolbarState == ToolbarVisibility.dismissed);
|
toolbarState == ToolbarVisibility.dismissed);
|
||||||
|
|
||||||
@@ -812,17 +821,14 @@ class _ProgressIndicatorBar extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final value = ref.watch(
|
final tabId = ref.watch(selectedTabProvider);
|
||||||
selectedTabStateProvider.select((state) {
|
final isLoading = ref.watch(
|
||||||
if (state?.isLoading == true) {
|
tabStateProvider(tabId).select((state) => state?.isLoading == true),
|
||||||
return state?.progress ?? 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
//When not loading we assumed finished
|
|
||||||
return 100;
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
//When not loading we assumed finished
|
||||||
|
final value = isLoading ? ref.watch(tabProgressProvider(tabId)) : 100;
|
||||||
|
|
||||||
return Visibility(
|
return Visibility(
|
||||||
visible: value < 100,
|
visible: value < 100,
|
||||||
child: LinearProgressIndicator(value: value / 100),
|
child: LinearProgressIndicator(value: value / 100),
|
||||||
@@ -1182,13 +1188,26 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
// Calculate bottom toolbar size for FAB and sheet positioning
|
// Calculate bottom toolbar size for FAB and sheet positioning
|
||||||
final Size bottomAppBarContentSize;
|
final Size bottomAppBarContentSize;
|
||||||
|
// The same size computed as if no sheet were displayed. `ViewTabsSheet`
|
||||||
|
// hides the main toolbar and the quick tab switcher, which shrinks the bar
|
||||||
|
// by 48-152px — and that value feeds the browser's viewport geometry
|
||||||
|
// (dynamic toolbar inset, vertical clipping and the Positioned that wraps
|
||||||
|
// the platform view). Resizing the Gecko viewport when a sheet opens costs
|
||||||
|
// a page reflow plus, under hybrid composition, a native surface
|
||||||
|
// recreation — all in the same frame as the sheet's slide-in animation.
|
||||||
|
// The sheet is drawn *on top* of the page, so the page's viewport has no
|
||||||
|
// reason to change: the inset math below uses this frozen value while the
|
||||||
|
// widgets that actually render the bar keep the real one.
|
||||||
|
final Size viewportBottomAppBarContentSize;
|
||||||
if (isSmallWebActive) {
|
if (isSmallWebActive) {
|
||||||
bottomAppBarContentSize = const Size.fromHeight(
|
bottomAppBarContentSize = const Size.fromHeight(
|
||||||
SmallWebBrowserOverlay.barHeight,
|
SmallWebBrowserOverlay.barHeight,
|
||||||
);
|
);
|
||||||
|
viewportBottomAppBarContentSize = bottomAppBarContentSize;
|
||||||
} else if (isRail) {
|
} else if (isRail) {
|
||||||
// The rail occupies a side, not the bottom; no bottom bar is rendered.
|
// The rail occupies a side, not the bottom; no bottom bar is rendered.
|
||||||
bottomAppBarContentSize = Size.zero;
|
bottomAppBarContentSize = Size.zero;
|
||||||
|
viewportBottomAppBarContentSize = bottomAppBarContentSize;
|
||||||
} else {
|
} else {
|
||||||
// Pass actual displayedSheet to get correct height when ViewTabsSheet hides main toolbar
|
// Pass actual displayedSheet to get correct height when ViewTabsSheet hides main toolbar
|
||||||
bottomAppBarContentSize = BrowserBottomAppBar(
|
bottomAppBarContentSize = BrowserBottomAppBar(
|
||||||
@@ -1198,10 +1217,21 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
isSmallWebMode: false,
|
isSmallWebMode: false,
|
||||||
displayedSheet: displayedSheet,
|
displayedSheet: displayedSheet,
|
||||||
).preferredSize;
|
).preferredSize;
|
||||||
|
viewportBottomAppBarContentSize = displayedSheet == null
|
||||||
|
? bottomAppBarContentSize
|
||||||
|
: BrowserBottomAppBar(
|
||||||
|
showMainToolbar: tabBarPosition == TabBarPosition.bottom,
|
||||||
|
showContextualToolbar: showContextualToolbar,
|
||||||
|
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
|
||||||
|
isSmallWebMode: false,
|
||||||
|
displayedSheet: null,
|
||||||
|
).preferredSize;
|
||||||
}
|
}
|
||||||
// Total height includes safe area padding
|
// Total height includes safe area padding
|
||||||
final bottomAppBarTotalHeight =
|
final bottomAppBarTotalHeight =
|
||||||
bottomAppBarContentSize.height + bottomSafeArea;
|
bottomAppBarContentSize.height + bottomSafeArea;
|
||||||
|
final viewportBottomAppBarTotalHeight =
|
||||||
|
viewportBottomAppBarContentSize.height + bottomSafeArea;
|
||||||
|
|
||||||
// Side rail width reservation (vertical positions only): the fixed content
|
// Side rail width reservation (vertical positions only): the fixed content
|
||||||
// width plus the system safe-area inset on the rail's outer edge.
|
// width plus the system safe-area inset on the rail's outer edge.
|
||||||
@@ -1262,7 +1292,8 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
final autoHideToolbarHeight = switch (tabBarPosition) {
|
final autoHideToolbarHeight = switch (tabBarPosition) {
|
||||||
// Gecko's dynamic toolbar value is consumed as a bottom inset by Dart and
|
// Gecko's dynamic toolbar value is consumed as a bottom inset by Dart and
|
||||||
// native UI. The top toolbar itself is positioned with Flutter's topOffset.
|
// native UI. The top toolbar itself is positioned with Flutter's topOffset.
|
||||||
TabBarPosition.top || TabBarPosition.bottom => bottomAppBarTotalHeight,
|
TabBarPosition.top ||
|
||||||
|
TabBarPosition.bottom => viewportBottomAppBarTotalHeight,
|
||||||
TabBarPosition.left || TabBarPosition.right => 0.0,
|
TabBarPosition.left || TabBarPosition.right => 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1348,7 +1379,7 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
// When visible/dismissed or overridden by keyboard/loading: no clipping.
|
// When visible/dismissed or overridden by keyboard/loading: no clipping.
|
||||||
final hiddenToolbarClippingPx = switch (tabBarPosition) {
|
final hiddenToolbarClippingPx = switch (tabBarPosition) {
|
||||||
TabBarPosition.top || TabBarPosition.bottom =>
|
TabBarPosition.top || TabBarPosition.bottom =>
|
||||||
-(bottomAppBarTotalHeight * pixelRatio).round(),
|
-(viewportBottomAppBarTotalHeight * pixelRatio).round(),
|
||||||
TabBarPosition.left || TabBarPosition.right => 0,
|
TabBarPosition.left || TabBarPosition.right => 0,
|
||||||
};
|
};
|
||||||
final targetClippingPx =
|
final targetClippingPx =
|
||||||
@@ -1402,7 +1433,7 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
keyboardVisible,
|
keyboardVisible,
|
||||||
tabIsLoading,
|
tabIsLoading,
|
||||||
tabInFullScreen,
|
tabInFullScreen,
|
||||||
bottomAppBarTotalHeight,
|
viewportBottomAppBarTotalHeight,
|
||||||
pixelRatio,
|
pixelRatio,
|
||||||
selectedTabId,
|
selectedTabId,
|
||||||
tabBarPosition,
|
tabBarPosition,
|
||||||
@@ -1601,6 +1632,12 @@ class _SheetContainer extends HookConsumerWidget {
|
|||||||
required this.bottomAppBarHeight,
|
required this.bottomAppBarHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// How far the scrim is allowed to extend *under* the sheet. The sheets round
|
||||||
|
/// their top corners (radius 28), and the page shows through those cutouts —
|
||||||
|
/// so the scrim has to reach a little past the sheet's top edge or the
|
||||||
|
/// corners would reveal unscrimmed content.
|
||||||
|
static const _sheetCornerOverlap = 32.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -1608,7 +1645,29 @@ class _SheetContainer extends HookConsumerWidget {
|
|||||||
Theme.of(context).bottomSheetTheme.modalBarrierColor ??
|
Theme.of(context).bottomSheetTheme.modalBarrierColor ??
|
||||||
colorScheme.scrim.withValues(alpha: 0.5);
|
colorScheme.scrim.withValues(alpha: 0.5);
|
||||||
|
|
||||||
bool dismissOnThreshold(DraggableScrollableNotification notification) {
|
final (sheet, initialExtent) = switch (displayedSheet) {
|
||||||
|
ViewTabsSheet() => (
|
||||||
|
_ViewTabsSheet(maxChildSize: relativeSafeArea),
|
||||||
|
_ViewTabsSheet.initialHeight,
|
||||||
|
),
|
||||||
|
final SiteSettingsSheet parameter => (
|
||||||
|
_SiteSettingsSheet(
|
||||||
|
initialTabState: parameter.tabState,
|
||||||
|
maxChildSize: relativeSafeArea,
|
||||||
|
bottomAppBarHeight: bottomAppBarHeight,
|
||||||
|
),
|
||||||
|
_SiteSettingsSheet.initialHeight,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drives the scrim's height. Seeded with the sheet's initial extent so the
|
||||||
|
// very first frame is already clipped correctly, then tracked from the
|
||||||
|
// sheet's own drag notifications.
|
||||||
|
final sheetExtent = useValueNotifier(initialExtent);
|
||||||
|
|
||||||
|
bool onSheetNotification(DraggableScrollableNotification notification) {
|
||||||
|
sheetExtent.value = notification.extent;
|
||||||
|
|
||||||
if (notification.extent <= 0.1) {
|
if (notification.extent <= 0.1) {
|
||||||
logger.i('Dismissing sheet, reached min extend');
|
logger.i('Dismissing sheet, reached min extend');
|
||||||
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
|
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
|
||||||
@@ -1617,36 +1676,61 @@ class _SheetContainer extends HookConsumerWidget {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestureDetector(
|
return LayoutBuilder(
|
||||||
onTap: () {
|
builder: (context, constraints) {
|
||||||
// Dismiss sheet when tapping outside
|
final available = constraints.maxHeight;
|
||||||
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// Scrim. Hit-testing stays full-area (opaque) so tapping anywhere
|
||||||
|
// the sheet doesn't occupy still dismisses — including beside a
|
||||||
|
// width-constrained sheet — but the *paint* is clipped to the band
|
||||||
|
// above the sheet. Under hybrid composition every pixel painted
|
||||||
|
// over the platform view goes through an overlay surface, and once
|
||||||
|
// the tab sheet settles near full extent almost all of a
|
||||||
|
// full-screen scrim is hidden behind it anyway.
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(bottomSheetControllerProvider.notifier)
|
||||||
|
.requestDismiss();
|
||||||
|
},
|
||||||
|
child: ValueListenableBuilder<double>(
|
||||||
|
valueListenable: sheetExtent,
|
||||||
|
builder: (context, extent, _) {
|
||||||
|
final scrimHeight =
|
||||||
|
(available * (1.0 - extent) + _sheetCornerOverlap)
|
||||||
|
.clamp(0.0, available);
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: SizedBox(
|
||||||
|
height: scrimHeight,
|
||||||
|
width: double.infinity,
|
||||||
|
child: ColoredBox(color: modalBarrierColor),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// The sheet itself sits above the scrim, so taps on it hit its own
|
||||||
|
// widgets and taps above it fall through to the scrim's detector.
|
||||||
|
Positioned.fill(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: NotificationListener<DraggableScrollableNotification>(
|
||||||
|
onNotification: onSheetNotification,
|
||||||
|
child: sheet,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: ColoredBox(
|
|
||||||
color: modalBarrierColor,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () {}, // Prevent tap from propagating to parent
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.bottomCenter,
|
|
||||||
child: switch (displayedSheet) {
|
|
||||||
ViewTabsSheet() =>
|
|
||||||
NotificationListener<DraggableScrollableNotification>(
|
|
||||||
onNotification: dismissOnThreshold,
|
|
||||||
child: _ViewTabsSheet(maxChildSize: relativeSafeArea),
|
|
||||||
),
|
|
||||||
final SiteSettingsSheet parameter =>
|
|
||||||
NotificationListener<DraggableScrollableNotification>(
|
|
||||||
onNotification: dismissOnThreshold,
|
|
||||||
child: _SiteSettingsSheet(
|
|
||||||
initialTabState: parameter.tabState,
|
|
||||||
maxChildSize: relativeSafeArea,
|
|
||||||
bottomAppBarHeight: bottomAppBarHeight,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1810,7 +1894,9 @@ class _Browser extends HookConsumerWidget {
|
|||||||
.toggleReaderView(false);
|
.toggleReaderView(false);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else if (tabState?.historyState.canGoBack == true) {
|
} else if (ref
|
||||||
|
.read(tabHistoryStateProvider(tabState?.id))
|
||||||
|
.canGoBack) {
|
||||||
lastBackButtonPress.value = null;
|
lastBackButtonPress.value = null;
|
||||||
|
|
||||||
final controller = ref.read(selectedTabSessionProvider);
|
final controller = ref.read(selectedTabSessionProvider);
|
||||||
@@ -2036,6 +2122,10 @@ class _SiteSettingsSheet extends HookConsumerWidget {
|
|||||||
class _ViewTabsSheet extends HookConsumerWidget {
|
class _ViewTabsSheet extends HookConsumerWidget {
|
||||||
final double maxChildSize;
|
final double maxChildSize;
|
||||||
|
|
||||||
|
/// Matches [DraggableScrollableSheet]'s default, made explicit so the
|
||||||
|
/// scrim in [_SheetContainer] can seed its clip from it.
|
||||||
|
static const initialHeight = 0.5;
|
||||||
|
|
||||||
const _ViewTabsSheet({this.maxChildSize = 1.0});
|
const _ViewTabsSheet({this.maxChildSize = 1.0});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2061,6 +2151,7 @@ class _ViewTabsSheet extends HookConsumerWidget {
|
|||||||
key: ValueKey(tabsReorderable),
|
key: ValueKey(tabsReorderable),
|
||||||
controller: draggableScrollableController,
|
controller: draggableScrollableController,
|
||||||
expand: false,
|
expand: false,
|
||||||
|
initialChildSize: initialHeight,
|
||||||
minChildSize: 0.1,
|
minChildSize: 0.1,
|
||||||
maxChildSize: maxChildSize,
|
maxChildSize: maxChildSize,
|
||||||
builder: (context, scrollController) {
|
builder: (context, scrollController) {
|
||||||
|
|||||||
+25
-48
@@ -20,7 +20,6 @@
|
|||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:ui' as ui;
|
|
||||||
|
|
||||||
import 'package:fading_scroll/fading_scroll.dart';
|
import 'package:fading_scroll/fading_scroll.dart';
|
||||||
import 'package:fast_equatable/fast_equatable.dart';
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
@@ -46,6 +45,7 @@ import 'package:weblibre/features/geckoview/domain/entities/tab_container_select
|
|||||||
import 'package:weblibre/features/geckoview/domain/providers.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/desktop_mode.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
|
||||||
@@ -70,6 +70,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co
|
|||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_relation_visibility.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_relation_visibility.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart';
|
import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
|
||||||
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
|
||||||
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
|
||||||
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
|
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
|
||||||
@@ -248,9 +249,7 @@ class _NavigationRow extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final history = ref.watch(
|
final history = ref.watch(tabHistoryStateProvider(selectedTabId));
|
||||||
tabStateProvider(selectedTabId).select((value) => value?.historyState),
|
|
||||||
);
|
|
||||||
final host = ref.watch(
|
final host = ref.watch(
|
||||||
tabStateProvider(selectedTabId).select((value) => value?.url.host),
|
tabStateProvider(selectedTabId).select((value) => value?.url.host),
|
||||||
);
|
);
|
||||||
@@ -280,7 +279,7 @@ class _NavigationRow extends HookConsumerWidget {
|
|||||||
child: _buildNavIcon(
|
child: _buildNavIcon(
|
||||||
icon: isLoading ? Icons.close : Icons.arrow_back,
|
icon: isLoading ? Icons.close : Icons.arrow_back,
|
||||||
label: isLoading ? 'Stop' : 'Back',
|
label: isLoading ? 'Stop' : 'Back',
|
||||||
disabled: !isLoading && history?.canGoBack != true,
|
disabled: !isLoading && !history.canGoBack,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final controller = ref.read(
|
final controller = ref.read(
|
||||||
tabSessionProvider(tabId: selectedTabId).notifier,
|
tabSessionProvider(tabId: selectedTabId).notifier,
|
||||||
@@ -296,7 +295,7 @@ class _NavigationRow extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
if (context.mounted) Navigator.pop(context);
|
if (context.mounted) Navigator.pop(context);
|
||||||
},
|
},
|
||||||
onLongPress: isLoading || history?.canGoBack != true
|
onLongPress: isLoading || !history.canGoBack
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
if (backMenuController.isOpen) {
|
if (backMenuController.isOpen) {
|
||||||
@@ -314,14 +313,14 @@ class _NavigationRow extends HookConsumerWidget {
|
|||||||
child: _buildNavIcon(
|
child: _buildNavIcon(
|
||||||
icon: Icons.arrow_forward,
|
icon: Icons.arrow_forward,
|
||||||
label: 'Forward',
|
label: 'Forward',
|
||||||
disabled: history?.canGoForward != true,
|
disabled: !history.canGoForward,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await ref
|
await ref
|
||||||
.read(tabSessionProvider(tabId: selectedTabId).notifier)
|
.read(tabSessionProvider(tabId: selectedTabId).notifier)
|
||||||
.goForward();
|
.goForward();
|
||||||
if (context.mounted) Navigator.pop(context);
|
if (context.mounted) Navigator.pop(context);
|
||||||
},
|
},
|
||||||
onLongPress: history?.canGoForward != true
|
onLongPress: !history.canGoForward
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
if (forwardMenuController.isOpen) {
|
if (forwardMenuController.isOpen) {
|
||||||
@@ -899,7 +898,7 @@ class _TranslatePageTile extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final engineState = ref.watch(translationEngineStateProvider);
|
final engineState = ref.watch(translationEngineStateProvider);
|
||||||
final translationState = ref.watch(
|
final translationState = ref.watch(
|
||||||
tabStateProvider(selectedTabId).select((s) => s?.translationState),
|
tabTranslationStateProvider(selectedTabId),
|
||||||
);
|
);
|
||||||
final readerActive = ref.watch(
|
final readerActive = ref.watch(
|
||||||
tabStateProvider(
|
tabStateProvider(
|
||||||
@@ -912,7 +911,7 @@ class _TranslatePageTile extends ConsumerWidget {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final isTranslated = translationState?.isTranslated ?? false;
|
final isTranslated = translationState.isTranslated;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -1470,29 +1469,15 @@ class _ShareExpansion extends HookConsumerWidget {
|
|||||||
final ts = ref.read(tabStateProvider(selectedTabId))!;
|
final ts = ref.read(tabStateProvider(selectedTabId))!;
|
||||||
|
|
||||||
if (screenshot != null) {
|
if (screenshot != null) {
|
||||||
ui.decodeImageFromList(screenshot, (result) async {
|
final png = await encodeScreenshotAsPng(screenshot);
|
||||||
try {
|
|
||||||
final png = await result.toByteData(
|
|
||||||
format: ui.ImageByteFormat.png,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (png != null) {
|
if (png != null) {
|
||||||
final file = XFile.fromData(
|
final file = XFile.fromData(png, mimeType: 'image/png');
|
||||||
png.buffer.asUint8List(),
|
|
||||||
mimeType: 'image/png',
|
|
||||||
);
|
|
||||||
|
|
||||||
await SharePlus.instance.share(
|
await SharePlus.instance.share(
|
||||||
ShareParams(
|
ShareParams(files: [file], subject: ts.titleOrAuthority),
|
||||||
files: [file],
|
);
|
||||||
subject: ts.titleOrAuthority,
|
}
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
result.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted) Navigator.pop(context);
|
if (context.mounted) Navigator.pop(context);
|
||||||
@@ -1771,24 +1756,16 @@ class _ExportExpansion extends ConsumerWidget {
|
|||||||
final ts = ref.read(tabStateProvider(selectedTabId))!;
|
final ts = ref.read(tabStateProvider(selectedTabId))!;
|
||||||
|
|
||||||
if (screenshot != null) {
|
if (screenshot != null) {
|
||||||
ui.decodeImageFromList(screenshot, (result) async {
|
final png = await encodeScreenshotAsPng(screenshot);
|
||||||
try {
|
|
||||||
final png = await result.toByteData(
|
|
||||||
format: ui.ImageByteFormat.png,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (png != null) {
|
if (png != null) {
|
||||||
await FilePicker.saveFile(
|
await FilePicker.saveFile(
|
||||||
fileName: '${ts.titleOrAuthority}.png',
|
fileName: '${ts.titleOrAuthority}.png',
|
||||||
type: FileType.custom,
|
type: FileType.custom,
|
||||||
allowedExtensions: ['png'],
|
allowedExtensions: ['png'],
|
||||||
bytes: png.buffer.asUint8List(),
|
bytes: png,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
result.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted) Navigator.pop(context);
|
if (context.mounted) Navigator.pop(context);
|
||||||
|
|||||||
+2
-5
@@ -815,8 +815,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
|||||||
)
|
)
|
||||||
.value
|
.value
|
||||||
: null;
|
: null;
|
||||||
final sandboxCaptureMap =
|
final sandboxSourceUris = ref.watch(sandboxSourceUrisProvider).value;
|
||||||
ref.watch(sandboxCaptureMapProvider).value ?? const {};
|
|
||||||
// Reorder is only meaningful when the bar renders the user's actual tab
|
// Reorder is only meaningful when the bar renders the user's actual tab
|
||||||
// order (containerTabs). Other modes (lastUsedTabs / MRU) sort by recency,
|
// order (containerTabs). Other modes (lastUsedTabs / MRU) sort by recency,
|
||||||
// so dragging would just snap back on the next tab switch.
|
// so dragging would just snap back on the next tab switch.
|
||||||
@@ -870,9 +869,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
|||||||
selectedTabId: selectedTabId,
|
selectedTabId: selectedTabId,
|
||||||
pinnedTabIds: pinnedTabIds,
|
pinnedTabIds: pinnedTabIds,
|
||||||
tabDepthById: tabDepthById,
|
tabDepthById: tabDepthById,
|
||||||
sandboxSourceUri: parseSandboxSource(
|
sandboxSourceUri: sandboxSourceUris[state.$1.id],
|
||||||
sandboxCaptureMap[state.$1.id],
|
|
||||||
),
|
|
||||||
isPlaceholder:
|
isPlaceholder:
|
||||||
!restoreComplete && !nativeTabIds.contains(state.$1.id),
|
!restoreComplete && !nativeTabIds.contains(state.$1.id),
|
||||||
),
|
),
|
||||||
|
|||||||
+10
@@ -34,6 +34,7 @@ import 'package:weblibre/features/app_links/domain/services/app_link_policy_repl
|
|||||||
import 'package:weblibre/features/bangs/data/models/web_search_bang.dart';
|
import 'package:weblibre/features/bangs/data/models/web_search_bang.dart';
|
||||||
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
||||||
import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart';
|
import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.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/entities/tab_container_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/browser_extension.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/browser_extension.dart';
|
||||||
@@ -122,6 +123,15 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-tree sheets (tab tray, site settings, …) are not routes — they are
|
||||||
|
// Stack layers driven by [bottomSheetControllerProvider] — so the route
|
||||||
|
// check above does not catch them. They occlude the browser just the same,
|
||||||
|
// and the resulting thumbnail event would rebuild the tray that is on
|
||||||
|
// screen, so skip the capture while one is displayed.
|
||||||
|
if (ref.read(bottomSheetControllerProvider) != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(selectedTabSessionProvider)
|
.read(selectedTabSessionProvider)
|
||||||
.requestScreenshot(requireImageResult: false)
|
.requestScreenshot(requireImageResult: false)
|
||||||
|
|||||||
+4
-5
@@ -21,6 +21,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/entities/states/history.dart';
|
import 'package:weblibre/features/geckoview/domain/entities/states/history.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/history_menu_item.dart';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/history_menu_item.dart';
|
||||||
|
|
||||||
@@ -42,9 +43,7 @@ class HistoryMenu extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final historyState = ref.watch(
|
final historyState = ref.watch(tabHistoryStateProvider(selectedTabId));
|
||||||
tabStateProvider(selectedTabId).select((state) => state?.historyState),
|
|
||||||
);
|
|
||||||
|
|
||||||
final menuItems = useMemoized(() => _buildMenuItems(historyState), [
|
final menuItems = useMemoized(() => _buildMenuItems(historyState), [
|
||||||
historyState,
|
historyState,
|
||||||
@@ -58,8 +57,8 @@ class HistoryMenu extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildMenuItems(HistoryState? historyState) {
|
List<Widget> _buildMenuItems(HistoryState historyState) {
|
||||||
if (historyState == null || historyState.items.isEmpty) {
|
if (historyState.items.isEmpty) {
|
||||||
return [
|
return [
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
+17
-39
@@ -17,7 +17,6 @@
|
|||||||
* You should have received a copy of the GNU Affero General Public License
|
* 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/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'dart:ui' as ui;
|
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -35,6 +34,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/dialog
|
|||||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.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/sync/domain/repositories/sync.dart';
|
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||||
@@ -235,29 +235,15 @@ class ShareScreenshotMenuItemButton extends HookConsumerWidget {
|
|||||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||||
|
|
||||||
if (screenshot != null) {
|
if (screenshot != null) {
|
||||||
ui.decodeImageFromList(screenshot, (result) async {
|
final png = await encodeScreenshotAsPng(screenshot);
|
||||||
try {
|
|
||||||
final png = await result.toByteData(
|
|
||||||
format: ui.ImageByteFormat.png,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (png != null) {
|
if (png != null) {
|
||||||
final file = XFile.fromData(
|
final file = XFile.fromData(png, mimeType: 'image/png');
|
||||||
png.buffer.asUint8List(),
|
|
||||||
mimeType: 'image/png',
|
|
||||||
);
|
|
||||||
|
|
||||||
await SharePlus.instance.share(
|
await SharePlus.instance.share(
|
||||||
ShareParams(
|
ShareParams(files: [file], subject: tabState.titleOrAuthority),
|
||||||
files: [file],
|
);
|
||||||
subject: tabState.titleOrAuthority,
|
}
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
result.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -290,24 +276,16 @@ class ExportScreenshotMenuItemButton extends HookConsumerWidget {
|
|||||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||||
|
|
||||||
if (screenshot != null) {
|
if (screenshot != null) {
|
||||||
ui.decodeImageFromList(screenshot, (result) async {
|
final png = await encodeScreenshotAsPng(screenshot);
|
||||||
try {
|
|
||||||
final png = await result.toByteData(
|
|
||||||
format: ui.ImageByteFormat.png,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (png != null) {
|
if (png != null) {
|
||||||
await FilePicker.saveFile(
|
await FilePicker.saveFile(
|
||||||
fileName: '${tabState.titleOrAuthority}.png',
|
fileName: '${tabState.titleOrAuthority}.png',
|
||||||
type: FileType.custom,
|
type: FileType.custom,
|
||||||
allowedExtensions: ['png'],
|
allowedExtensions: ['png'],
|
||||||
bytes: png.buffer.asUint8List(),
|
bytes: png,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
result.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
+8
-22
@@ -19,7 +19,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:ui' as ui;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -37,6 +36,7 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/domain/serv
|
|||||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart';
|
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart';
|
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
|
||||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||||
@@ -164,29 +164,15 @@ class ShareBottomSheet extends HookConsumerWidget {
|
|||||||
final ts = ref.read(tabStateProvider(selectedTabId))!;
|
final ts = ref.read(tabStateProvider(selectedTabId))!;
|
||||||
|
|
||||||
if (screenshot != null) {
|
if (screenshot != null) {
|
||||||
ui.decodeImageFromList(screenshot, (result) async {
|
final png = await encodeScreenshotAsPng(screenshot);
|
||||||
try {
|
|
||||||
final png = await result.toByteData(
|
|
||||||
format: ui.ImageByteFormat.png,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (png != null) {
|
if (png != null) {
|
||||||
final file = XFile.fromData(
|
final file = XFile.fromData(png, mimeType: 'image/png');
|
||||||
png.buffer.asUint8List(),
|
|
||||||
mimeType: 'image/png',
|
|
||||||
);
|
|
||||||
|
|
||||||
await SharePlus.instance.share(
|
await SharePlus.instance.share(
|
||||||
ShareParams(
|
ShareParams(files: [file], subject: ts.titleOrAuthority),
|
||||||
files: [file],
|
);
|
||||||
subject: ts.titleOrAuthority,
|
}
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
result.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted) Navigator.pop(context);
|
if (context.mounted) Navigator.pop(context);
|
||||||
|
|||||||
+6
-7
@@ -33,6 +33,7 @@ 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/entities/tab_container_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers.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/desktop_mode.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||||
@@ -757,7 +758,7 @@ class _TranslatePageMenuItem extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final engineState = ref.watch(translationEngineStateProvider);
|
final engineState = ref.watch(translationEngineStateProvider);
|
||||||
final translationState = ref.watch(
|
final translationState = ref.watch(
|
||||||
tabStateProvider(selectedTabId).select((s) => s?.translationState),
|
tabTranslationStateProvider(selectedTabId),
|
||||||
);
|
);
|
||||||
final readerActive = ref.watch(
|
final readerActive = ref.watch(
|
||||||
tabStateProvider(
|
tabStateProvider(
|
||||||
@@ -770,7 +771,7 @@ class _TranslatePageMenuItem extends ConsumerWidget {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final isTranslated = translationState?.isTranslated ?? false;
|
final isTranslated = translationState.isTranslated;
|
||||||
|
|
||||||
return MenuItemButton(
|
return MenuItemButton(
|
||||||
closeOnActivate: false,
|
closeOnActivate: false,
|
||||||
@@ -833,9 +834,7 @@ class _NavigationButtonsRow extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final history = ref.watch(
|
final history = ref.watch(tabHistoryStateProvider(selectedTabId));
|
||||||
tabStateProvider(selectedTabId).select((value) => value?.historyState),
|
|
||||||
);
|
|
||||||
|
|
||||||
final isLoading = ref.watch(
|
final isLoading = ref.watch(
|
||||||
selectedTabStateProvider.select((state) => state?.isLoading ?? false),
|
selectedTabStateProvider.select((state) => state?.isLoading ?? false),
|
||||||
@@ -848,7 +847,7 @@ class _NavigationButtonsRow extends ConsumerWidget {
|
|||||||
selectedTabId: selectedTabId,
|
selectedTabId: selectedTabId,
|
||||||
isLoading: isLoading,
|
isLoading: isLoading,
|
||||||
menuControllerToClose: controller,
|
menuControllerToClose: controller,
|
||||||
canGoBack: history?.canGoBack == true,
|
canGoBack: history.canGoBack,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 48, child: VerticalDivider()),
|
const SizedBox(height: 48, child: VerticalDivider()),
|
||||||
@@ -856,7 +855,7 @@ class _NavigationButtonsRow extends ConsumerWidget {
|
|||||||
child: NavigateForwardButton(
|
child: NavigateForwardButton(
|
||||||
selectedTabId: selectedTabId,
|
selectedTabId: selectedTabId,
|
||||||
menuControllerToClose: controller,
|
menuControllerToClose: controller,
|
||||||
canGoForward: history?.canGoForward == true,
|
canGoForward: history.canGoForward,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
+219
-195
@@ -25,6 +25,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/design/app_colors.dart';
|
import 'package:weblibre/core/design/app_colors.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_close_confirmation.dart';
|
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_close_confirmation.dart';
|
||||||
@@ -174,6 +175,8 @@ class GridTabPreview extends HookConsumerWidget {
|
|||||||
) ??
|
) ??
|
||||||
TabState.$default(tabId);
|
TabState.$default(tabId);
|
||||||
|
|
||||||
|
final thumbnail = ref.watch(tabThumbnailProvider(tabId));
|
||||||
|
|
||||||
final sandboxSourceUri = ref.watch(
|
final sandboxSourceUri = ref.watch(
|
||||||
sandboxSourceUriForTabProvider(tabId: tabId),
|
sandboxSourceUriForTabProvider(tabId: tabId),
|
||||||
);
|
);
|
||||||
@@ -233,13 +236,9 @@ class GridTabPreview extends HookConsumerWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (tabState.thumbnail != null &&
|
if (thumbnail != null && !thumbnail.isDisposed)
|
||||||
!tabState.thumbnail!.isDisposed)
|
|
||||||
RepaintBoundary(
|
RepaintBoundary(
|
||||||
child: SafeRawImage(
|
child: SafeRawImage(image: thumbnail, fit: BoxFit.cover),
|
||||||
image: tabState.thumbnail,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Center(child: TabIcon(tabState: tabState, iconSize: 48)),
|
Center(child: TabIcon(tabState: tabState, iconSize: 48)),
|
||||||
@@ -498,6 +497,12 @@ class ListTabPreview extends HookConsumerWidget {
|
|||||||
generalSettingsWithDefaultsProvider.select((s) => s.tabListShowFavicons),
|
generalSettingsWithDefaultsProvider.select((s) => s.tabListShowFavicons),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Only the leading tile renders the thumbnail, and only when favicons are
|
||||||
|
// off — don't subscribe to screenshot churn otherwise.
|
||||||
|
final thumbnail = tabListShowFavicons
|
||||||
|
? null
|
||||||
|
: ref.watch(tabThumbnailProvider(tabId));
|
||||||
|
|
||||||
final extendedDeleteMenuController = useMenuController();
|
final extendedDeleteMenuController = useMenuController();
|
||||||
|
|
||||||
// ignore: avoid_bool_literals_in_conditional_expressions
|
// ignore: avoid_bool_literals_in_conditional_expressions
|
||||||
@@ -509,7 +514,7 @@ class ListTabPreview extends HookConsumerWidget {
|
|||||||
)
|
)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
final leadingWidget = switch ((tabListShowFavicons, tabState.thumbnail)) {
|
final leadingWidget = switch ((tabListShowFavicons, thumbnail)) {
|
||||||
(false, final thumbnail?) when !thumbnail.isDisposed => ClipRRect(
|
(false, final thumbnail?) when !thumbnail.isDisposed => ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
|
||||||
child: RepaintBoundary(
|
child: RepaintBoundary(
|
||||||
@@ -799,7 +804,104 @@ class SingleGridTabPreview extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final dragStartPosition = useRef(Offset.zero);
|
final dragStartPosition = useRef(Offset.zero);
|
||||||
final draggedDistance = useState(0.0);
|
// A ValueNotifier rather than useState: the drag fires per pointer event,
|
||||||
|
// and useState would rebuild this whole subtree — thumbnail, favicon,
|
||||||
|
// title, menus, badges — inside a scrolling list on every one. Only the
|
||||||
|
// ValueListenableBuilder below reruns now.
|
||||||
|
final draggedDistance = useValueNotifier(0.0);
|
||||||
|
|
||||||
|
// Built once per rebuild of *this* widget and handed to the builder as its
|
||||||
|
// `child`, so the drag never reconstructs it.
|
||||||
|
final preview = RepaintBoundary(
|
||||||
|
child: GridTabPreview(
|
||||||
|
tabId: tabId,
|
||||||
|
isActive: tabId == activeTabId,
|
||||||
|
showPinBadge: true,
|
||||||
|
groupToggle: groupToggle,
|
||||||
|
depth: depth,
|
||||||
|
onTap: () async {
|
||||||
|
if (tabId != activeTabId) {
|
||||||
|
// Offer to locate the match within the page instead of opening
|
||||||
|
// Find in Page unprompted (see #421). Shown before onClose so it
|
||||||
|
// surfaces on the root messenger and survives the tray closing.
|
||||||
|
final query = sourceSearchQuery;
|
||||||
|
if (query != null &&
|
||||||
|
query.isNotEmpty &&
|
||||||
|
ref.read(findInPageControllerProvider(tabId)) ==
|
||||||
|
FindInPageState.hidden()) {
|
||||||
|
final findController = ref.read(
|
||||||
|
findInPageControllerProvider(tabId).notifier,
|
||||||
|
);
|
||||||
|
ui_helper.showFindInPageSuggestion(
|
||||||
|
context,
|
||||||
|
query: query,
|
||||||
|
onFind: () => findController.findAll(text: query),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
//Close first to avoid rebuilds
|
||||||
|
onClose();
|
||||||
|
await ref.read(tabRepositoryProvider.notifier).selectTab(tabId);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDeleteAll: (host) async {
|
||||||
|
final containerId = await ref
|
||||||
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
|
.getTabContainerId(tabId);
|
||||||
|
|
||||||
|
final count = await ref
|
||||||
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
|
.closeAllTabsByHost(containerId, host);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ui_helper.showTabUndoClose(
|
||||||
|
context,
|
||||||
|
ref.read(tabRepositoryProvider.notifier).undoClose,
|
||||||
|
count: count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCloseSubtree: () async {
|
||||||
|
final subtreeIds = await ref
|
||||||
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
|
.getTabDescendants(tabId)
|
||||||
|
.then((descendants) => descendants.keys.toList());
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
final didClose = await closeTabsWithConfirmation(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
subtreeIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted && didClose) {
|
||||||
|
ui_helper.showTabUndoClose(
|
||||||
|
context,
|
||||||
|
ref.read(tabRepositoryProvider.notifier).undoClose,
|
||||||
|
count: subtreeIds.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDelete: () async {
|
||||||
|
onBeforeDelete?.call();
|
||||||
|
|
||||||
|
if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ui_helper.showTabUndoClose(
|
||||||
|
context,
|
||||||
|
ref.read(tabRepositoryProvider.notifier).undoClose,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onHorizontalDragStart: (details) {
|
onHorizontalDragStart: (details) {
|
||||||
@@ -807,12 +909,10 @@ class SingleGridTabPreview extends HookConsumerWidget {
|
|||||||
draggedDistance.value = 0.0;
|
draggedDistance.value = 0.0;
|
||||||
},
|
},
|
||||||
onHorizontalDragUpdate: (details) {
|
onHorizontalDragUpdate: (details) {
|
||||||
final cappedDistance = math.min(
|
draggedDistance.value = math.min(
|
||||||
(dragStartPosition.value - details.globalPosition).dx.abs(),
|
(dragStartPosition.value - details.globalPosition).dx.abs(),
|
||||||
deleteThreshold,
|
deleteThreshold,
|
||||||
);
|
);
|
||||||
|
|
||||||
draggedDistance.value = cappedDistance;
|
|
||||||
},
|
},
|
||||||
onHorizontalDragEnd: (details) async {
|
onHorizontalDragEnd: (details) async {
|
||||||
if (draggedDistance.value >= deleteThreshold) {
|
if (draggedDistance.value >= deleteThreshold) {
|
||||||
@@ -833,96 +933,14 @@ class SingleGridTabPreview extends HookConsumerWidget {
|
|||||||
|
|
||||||
draggedDistance.value = 0.0;
|
draggedDistance.value = 0.0;
|
||||||
},
|
},
|
||||||
child: Opacity(
|
child: ValueListenableBuilder<double>(
|
||||||
opacity: 1.0 - draggedDistance.value / deleteThreshold,
|
valueListenable: draggedDistance,
|
||||||
child: GridTabPreview(
|
// Kept unconditionally (rather than skipping it at rest) so the
|
||||||
tabId: tabId,
|
// element tree doesn't reshape on drag start/end. Opacity at 1.0
|
||||||
isActive: tabId == activeTabId,
|
// pushes no layer, so this costs nothing when not dragging.
|
||||||
showPinBadge: true,
|
builder: (context, distance, child) =>
|
||||||
groupToggle: groupToggle,
|
Opacity(opacity: 1.0 - distance / deleteThreshold, child: child),
|
||||||
depth: depth,
|
child: preview,
|
||||||
onTap: () async {
|
|
||||||
if (tabId != activeTabId) {
|
|
||||||
// Offer to locate the match within the page instead of opening
|
|
||||||
// Find in Page unprompted (see #421). Shown before onClose so it
|
|
||||||
// surfaces on the root messenger and survives the tray closing.
|
|
||||||
final query = sourceSearchQuery;
|
|
||||||
if (query != null &&
|
|
||||||
query.isNotEmpty &&
|
|
||||||
ref.read(findInPageControllerProvider(tabId)) ==
|
|
||||||
FindInPageState.hidden()) {
|
|
||||||
final findController = ref.read(
|
|
||||||
findInPageControllerProvider(tabId).notifier,
|
|
||||||
);
|
|
||||||
ui_helper.showFindInPageSuggestion(
|
|
||||||
context,
|
|
||||||
query: query,
|
|
||||||
onFind: () => findController.findAll(text: query),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
//Close first to avoid rebuilds
|
|
||||||
onClose();
|
|
||||||
await ref.read(tabRepositoryProvider.notifier).selectTab(tabId);
|
|
||||||
} else {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDeleteAll: (host) async {
|
|
||||||
final containerId = await ref
|
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
|
||||||
.getTabContainerId(tabId);
|
|
||||||
|
|
||||||
final count = await ref
|
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
|
||||||
.closeAllTabsByHost(containerId, host);
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ui_helper.showTabUndoClose(
|
|
||||||
context,
|
|
||||||
ref.read(tabRepositoryProvider.notifier).undoClose,
|
|
||||||
count: count,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCloseSubtree: () async {
|
|
||||||
final subtreeIds = await ref
|
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
|
||||||
.getTabDescendants(tabId)
|
|
||||||
.then((descendants) => descendants.keys.toList());
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
final didClose = await closeTabsWithConfirmation(
|
|
||||||
context,
|
|
||||||
ref,
|
|
||||||
subtreeIds,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (context.mounted && didClose) {
|
|
||||||
ui_helper.showTabUndoClose(
|
|
||||||
context,
|
|
||||||
ref.read(tabRepositoryProvider.notifier).undoClose,
|
|
||||||
count: subtreeIds.length,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDelete: () async {
|
|
||||||
onBeforeDelete?.call();
|
|
||||||
|
|
||||||
if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ui_helper.showTabUndoClose(
|
|
||||||
context,
|
|
||||||
ref.read(tabRepositoryProvider.notifier).undoClose,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -955,7 +973,100 @@ class SingleListTabPreview extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final dragStartPosition = useRef(Offset.zero);
|
final dragStartPosition = useRef(Offset.zero);
|
||||||
final draggedDistance = useState(0.0);
|
// See [SingleGridTabPreview]: per-pointer-event drag updates must not
|
||||||
|
// rebuild the row's subtree inside a scrolling list.
|
||||||
|
final draggedDistance = useValueNotifier(0.0);
|
||||||
|
|
||||||
|
final preview = RepaintBoundary(
|
||||||
|
child: ListTabPreview(
|
||||||
|
tabId: tabId,
|
||||||
|
isActive: tabId == activeTabId,
|
||||||
|
showPinBadge: true,
|
||||||
|
groupToggle: groupToggle,
|
||||||
|
depth: depth,
|
||||||
|
onTap: () async {
|
||||||
|
if (tabId != activeTabId) {
|
||||||
|
// Offer to locate the match within the page instead of opening
|
||||||
|
// Find in Page unprompted (see #421). Shown before onClose so it
|
||||||
|
// surfaces on the root messenger and survives the tray closing.
|
||||||
|
final query = sourceSearchQuery;
|
||||||
|
if (query != null &&
|
||||||
|
query.isNotEmpty &&
|
||||||
|
ref.read(findInPageControllerProvider(tabId)) ==
|
||||||
|
FindInPageState.hidden()) {
|
||||||
|
final findController = ref.read(
|
||||||
|
findInPageControllerProvider(tabId).notifier,
|
||||||
|
);
|
||||||
|
ui_helper.showFindInPageSuggestion(
|
||||||
|
context,
|
||||||
|
query: query,
|
||||||
|
onFind: () => findController.findAll(text: query),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
//Close first to avoid rebuilds
|
||||||
|
onClose();
|
||||||
|
await ref.read(tabRepositoryProvider.notifier).selectTab(tabId);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDeleteAll: (host) async {
|
||||||
|
final containerId = await ref
|
||||||
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
|
.getTabContainerId(tabId);
|
||||||
|
|
||||||
|
final count = await ref
|
||||||
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
|
.closeAllTabsByHost(containerId, host);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ui_helper.showTabUndoClose(
|
||||||
|
context,
|
||||||
|
ref.read(tabRepositoryProvider.notifier).undoClose,
|
||||||
|
count: count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCloseSubtree: () async {
|
||||||
|
final subtreeIds = await ref
|
||||||
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
|
.getTabDescendants(tabId)
|
||||||
|
.then((descendants) => descendants.keys.toList());
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
final didClose = await closeTabsWithConfirmation(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
subtreeIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted && didClose) {
|
||||||
|
ui_helper.showTabUndoClose(
|
||||||
|
context,
|
||||||
|
ref.read(tabRepositoryProvider.notifier).undoClose,
|
||||||
|
count: subtreeIds.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDelete: () async {
|
||||||
|
onBeforeDelete?.call();
|
||||||
|
|
||||||
|
if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ui_helper.showTabUndoClose(
|
||||||
|
context,
|
||||||
|
ref.read(tabRepositoryProvider.notifier).undoClose,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onHorizontalDragStart: (details) {
|
onHorizontalDragStart: (details) {
|
||||||
@@ -963,12 +1074,10 @@ class SingleListTabPreview extends HookConsumerWidget {
|
|||||||
draggedDistance.value = 0.0;
|
draggedDistance.value = 0.0;
|
||||||
},
|
},
|
||||||
onHorizontalDragUpdate: (details) {
|
onHorizontalDragUpdate: (details) {
|
||||||
final cappedDistance = math.min(
|
draggedDistance.value = math.min(
|
||||||
(dragStartPosition.value - details.globalPosition).dx.abs(),
|
(dragStartPosition.value - details.globalPosition).dx.abs(),
|
||||||
deleteThreshold,
|
deleteThreshold,
|
||||||
);
|
);
|
||||||
|
|
||||||
draggedDistance.value = cappedDistance;
|
|
||||||
},
|
},
|
||||||
onHorizontalDragEnd: (details) async {
|
onHorizontalDragEnd: (details) async {
|
||||||
if (draggedDistance.value >= deleteThreshold) {
|
if (draggedDistance.value >= deleteThreshold) {
|
||||||
@@ -989,96 +1098,11 @@ class SingleListTabPreview extends HookConsumerWidget {
|
|||||||
|
|
||||||
draggedDistance.value = 0.0;
|
draggedDistance.value = 0.0;
|
||||||
},
|
},
|
||||||
child: Opacity(
|
child: ValueListenableBuilder<double>(
|
||||||
opacity: 1.0 - draggedDistance.value / deleteThreshold,
|
valueListenable: draggedDistance,
|
||||||
child: ListTabPreview(
|
builder: (context, distance, child) =>
|
||||||
tabId: tabId,
|
Opacity(opacity: 1.0 - distance / deleteThreshold, child: child),
|
||||||
isActive: tabId == activeTabId,
|
child: preview,
|
||||||
showPinBadge: true,
|
|
||||||
groupToggle: groupToggle,
|
|
||||||
depth: depth,
|
|
||||||
onTap: () async {
|
|
||||||
if (tabId != activeTabId) {
|
|
||||||
// Offer to locate the match within the page instead of opening
|
|
||||||
// Find in Page unprompted (see #421). Shown before onClose so it
|
|
||||||
// surfaces on the root messenger and survives the tray closing.
|
|
||||||
final query = sourceSearchQuery;
|
|
||||||
if (query != null &&
|
|
||||||
query.isNotEmpty &&
|
|
||||||
ref.read(findInPageControllerProvider(tabId)) ==
|
|
||||||
FindInPageState.hidden()) {
|
|
||||||
final findController = ref.read(
|
|
||||||
findInPageControllerProvider(tabId).notifier,
|
|
||||||
);
|
|
||||||
ui_helper.showFindInPageSuggestion(
|
|
||||||
context,
|
|
||||||
query: query,
|
|
||||||
onFind: () => findController.findAll(text: query),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
//Close first to avoid rebuilds
|
|
||||||
onClose();
|
|
||||||
await ref.read(tabRepositoryProvider.notifier).selectTab(tabId);
|
|
||||||
} else {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDeleteAll: (host) async {
|
|
||||||
final containerId = await ref
|
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
|
||||||
.getTabContainerId(tabId);
|
|
||||||
|
|
||||||
final count = await ref
|
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
|
||||||
.closeAllTabsByHost(containerId, host);
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ui_helper.showTabUndoClose(
|
|
||||||
context,
|
|
||||||
ref.read(tabRepositoryProvider.notifier).undoClose,
|
|
||||||
count: count,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCloseSubtree: () async {
|
|
||||||
final subtreeIds = await ref
|
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
|
||||||
.getTabDescendants(tabId)
|
|
||||||
.then((descendants) => descendants.keys.toList());
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
final didClose = await closeTabsWithConfirmation(
|
|
||||||
context,
|
|
||||||
ref,
|
|
||||||
subtreeIds,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (context.mounted && didClose) {
|
|
||||||
ui_helper.showTabUndoClose(
|
|
||||||
context,
|
|
||||||
ref.read(tabRepositoryProvider.notifier).undoClose,
|
|
||||||
count: subtreeIds.length,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDelete: () async {
|
|
||||||
onBeforeDelete?.call();
|
|
||||||
|
|
||||||
if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ui_helper.showTabUndoClose(
|
|
||||||
context,
|
|
||||||
ref.read(tabRepositoryProvider.notifier).undoClose,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -26,6 +26,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/tab_state.dart';
|
||||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||||
@@ -67,18 +68,19 @@ class TranslationBottomSheet extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
final translationState = ref.watch(
|
||||||
final translationState = tabState?.translationState;
|
tabTranslationStateProvider(selectedTabId),
|
||||||
|
);
|
||||||
final engineState = ref.watch(translationEngineStateProvider);
|
final engineState = ref.watch(translationEngineStateProvider);
|
||||||
|
|
||||||
final fromLanguages = engineState?.fromLanguages?.nonNulls.toList() ?? [];
|
final fromLanguages = engineState?.fromLanguages?.nonNulls.toList() ?? [];
|
||||||
final toLanguages = engineState?.toLanguages?.nonNulls.toList() ?? [];
|
final toLanguages = engineState?.toLanguages?.nonNulls.toList() ?? [];
|
||||||
final suggestedFrom =
|
final suggestedFrom =
|
||||||
translationState?.detectedLanguageCode ??
|
translationState.detectedLanguageCode ??
|
||||||
translationState?.requestedFromLanguage;
|
translationState.requestedFromLanguage;
|
||||||
final suggestedTo =
|
final suggestedTo =
|
||||||
translationState?.userPreferredLanguageCode ??
|
translationState.userPreferredLanguageCode ??
|
||||||
translationState?.requestedToLanguage;
|
translationState.requestedToLanguage;
|
||||||
|
|
||||||
final selectedFrom = useState<String?>(suggestedFrom);
|
final selectedFrom = useState<String?>(suggestedFrom);
|
||||||
final selectedTo = useState<String?>(suggestedTo);
|
final selectedTo = useState<String?>(suggestedTo);
|
||||||
@@ -93,10 +95,10 @@ class TranslationBottomSheet extends HookConsumerWidget {
|
|||||||
return null;
|
return null;
|
||||||
}, [suggestedFrom, suggestedTo]);
|
}, [suggestedFrom, suggestedTo]);
|
||||||
|
|
||||||
final isTranslated = translationState?.isTranslated ?? false;
|
final isTranslated = translationState.isTranslated;
|
||||||
final isProcessing = translationState?.isTranslateProcessing ?? false;
|
final isProcessing = translationState.isTranslateProcessing;
|
||||||
final hasError = translationState?.hasError ?? false;
|
final hasError = translationState.hasError;
|
||||||
final errorName = translationState?.translationErrorName;
|
final errorName = translationState.translationErrorName;
|
||||||
final translatePhase = useState(_TranslatePhase.idle);
|
final translatePhase = useState(_TranslatePhase.idle);
|
||||||
|
|
||||||
final effectiveProcessing =
|
final effectiveProcessing =
|
||||||
|
|||||||
+5
-2
@@ -21,6 +21,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart';
|
import 'package:weblibre/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart';
|
import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart';
|
||||||
@@ -49,8 +50,10 @@ class FindInPageController extends _$FindInPageController {
|
|||||||
Future<void> findNext({required String fallbackText, bool forward = true}) {
|
Future<void> findNext({required String fallbackText, bool forward = true}) {
|
||||||
final service = ref.read(findInPageRepositoryProvider(tabId).notifier);
|
final service = ref.read(findInPageRepositoryProvider(tabId).notifier);
|
||||||
|
|
||||||
final hasMatches =
|
final hasMatches = ref
|
||||||
ref.read(tabStatesProvider)[tabId]?.findResultState.hasMatches == true;
|
.read(tabFindResultStatesProvider.notifier)
|
||||||
|
.resultFor(tabId)
|
||||||
|
.hasMatches;
|
||||||
|
|
||||||
state = state.copyWith.visible(true);
|
state = state.copyWith.visible(true);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ final class FindInPageControllerProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$findInPageControllerHash() =>
|
String _$findInPageControllerHash() =>
|
||||||
r'21ca6178016dc141fcfcb71afc158d9b8eed0a7e';
|
r'd2b90ef0c6096e04ad4067f48fb5e5170268446b';
|
||||||
|
|
||||||
final class FindInPageControllerFamily extends $Family
|
final class FindInPageControllerFamily extends $Family
|
||||||
with
|
with
|
||||||
|
|||||||
+5
-6
@@ -20,6 +20,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||||
@@ -41,15 +42,13 @@ class FindInPageWidget extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final findInPageState = ref.watch(findInPageControllerProvider(tabId));
|
final findInPageState = ref.watch(findInPageControllerProvider(tabId));
|
||||||
final searchResult = ref.watch(
|
final searchResult = ref.watch(tabFindResultStateProvider(tabId));
|
||||||
selectedTabStateProvider.select((state) => state?.findResultState),
|
|
||||||
);
|
|
||||||
final privateTabMode =
|
final privateTabMode =
|
||||||
ref.watch(tabStateProvider(tabId))?.tabMode == TabMode.private;
|
ref.watch(tabStateProvider(tabId))?.tabMode == TabMode.private;
|
||||||
|
|
||||||
final focusNode = useFocusNode();
|
final focusNode = useFocusNode();
|
||||||
final textController = useTextEditingController(
|
final textController = useTextEditingController(
|
||||||
text: searchResult?.lastSearchText ?? findInPageState.lastSearchText,
|
text: searchResult.lastSearchText ?? findInPageState.lastSearchText,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sync text field when the find-in-page query is set externally (e.g.,
|
// Sync text field when the find-in-page query is set externally (e.g.,
|
||||||
@@ -88,7 +87,7 @@ class FindInPageWidget extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Visibility(
|
return Visibility(
|
||||||
visible: findInPageState.visible || searchResult?.hasMatches == true,
|
visible: findInPageState.visible || searchResult.hasMatches,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: padding,
|
padding: padding,
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -125,7 +124,7 @@ class FindInPageWidget extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
(searchResult != null && searchResult.hasMatches)
|
searchResult.hasMatches
|
||||||
? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}'
|
? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}'
|
||||||
: 'Not found',
|
: 'Not found',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:isolate';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
@@ -28,7 +29,7 @@ import 'package:weblibre/core/logger.dart';
|
|||||||
import 'package:weblibre/domain/entities/equatable_image.dart';
|
import 'package:weblibre/domain/entities/equatable_image.dart';
|
||||||
import 'package:weblibre/utils/lru_cache.dart';
|
import 'package:weblibre/utils/lru_cache.dart';
|
||||||
|
|
||||||
final _cache = LRUCache<int, EquatableImage>(100);
|
final _cache = LRUCache<ImageIdentity, EquatableImage>(100);
|
||||||
const _defaultSvgIconSize = 32;
|
const _defaultSvgIconSize = 32;
|
||||||
|
|
||||||
/// Clears the global image decode cache.
|
/// Clears the global image decode cache.
|
||||||
@@ -42,13 +43,23 @@ Future<EquatableImage?> tryDecodeImage(
|
|||||||
int? targetHeight,
|
int? targetHeight,
|
||||||
bool allowUpscaling = true,
|
bool allowUpscaling = true,
|
||||||
}) async {
|
}) async {
|
||||||
final digest = secureHash(bytes);
|
// The decode options are part of the identity of the result, not just of the
|
||||||
|
// request: the same bytes decoded at a thumbnail's target width and at an
|
||||||
|
// icon's native size are different images. Keying on the content digest
|
||||||
|
// alone would both hand back a wrongly-sized image to whichever caller ran
|
||||||
|
// second, and make two genuinely different decodes compare equal.
|
||||||
|
final identity = (
|
||||||
|
digest: secureHash(bytes),
|
||||||
|
targetWidth: targetWidth,
|
||||||
|
targetHeight: targetHeight,
|
||||||
|
allowUpscaling: allowUpscaling,
|
||||||
|
);
|
||||||
|
|
||||||
final cached = _cache.get(digest);
|
final cached = _cache.get(identity);
|
||||||
if (cached?.value != null) {
|
if (cached?.value != null) {
|
||||||
return cached;
|
return cached;
|
||||||
} else if (cached != null) {
|
} else if (cached != null) {
|
||||||
_cache.remove(digest);
|
_cache.remove(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -60,10 +71,10 @@ Future<EquatableImage?> tryDecodeImage(
|
|||||||
);
|
);
|
||||||
|
|
||||||
final frameInfo = await codec.getNextFrame();
|
final frameInfo = await codec.getNextFrame();
|
||||||
final image = EquatableImage(frameInfo.image, hash: digest);
|
final image = EquatableImage(frameInfo.image, identity: identity);
|
||||||
|
|
||||||
if (image.value != null && image.value!.width > 0) {
|
if (image.value != null && image.value!.width > 0) {
|
||||||
_cache.set(digest, image);
|
_cache.set(identity, image);
|
||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
@@ -71,12 +82,12 @@ Future<EquatableImage?> tryDecodeImage(
|
|||||||
try {
|
try {
|
||||||
final svgImage = await _tryDecodeSvg(
|
final svgImage = await _tryDecodeSvg(
|
||||||
bytes,
|
bytes,
|
||||||
hash: digest,
|
identity: identity,
|
||||||
targetWidth: targetWidth,
|
targetWidth: targetWidth,
|
||||||
targetHeight: targetHeight,
|
targetHeight: targetHeight,
|
||||||
);
|
);
|
||||||
if (svgImage != null) {
|
if (svgImage != null) {
|
||||||
_cache.set(digest, svgImage);
|
_cache.set(identity, svgImage);
|
||||||
return svgImage;
|
return svgImage;
|
||||||
}
|
}
|
||||||
} catch (svgError, svgStackTrace) {
|
} catch (svgError, svgStackTrace) {
|
||||||
@@ -94,6 +105,46 @@ Future<EquatableImage?> tryDecodeImage(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Transcodes a native screenshot (delivered as WebP) to PNG off the main
|
||||||
|
/// isolate.
|
||||||
|
///
|
||||||
|
/// Decoding a full-resolution screenshot and re-encoding it as PNG costs tens
|
||||||
|
/// of milliseconds on the UI thread, and every caller does it right as a share
|
||||||
|
/// sheet or file picker is animating in — so the cost lands as a visible hitch.
|
||||||
|
/// dart:ui's codec APIs are usable from background isolates, so the whole
|
||||||
|
/// transcode runs there; if that ever fails we fall back to doing it inline
|
||||||
|
/// rather than losing the feature.
|
||||||
|
Future<Uint8List?> encodeScreenshotAsPng(Uint8List screenshot) async {
|
||||||
|
try {
|
||||||
|
return await Isolate.run(() => _transcodeToPng(screenshot));
|
||||||
|
} catch (e, s) {
|
||||||
|
logger.w(
|
||||||
|
'Screenshot PNG transcode failed in background isolate, retrying inline',
|
||||||
|
error: e,
|
||||||
|
stackTrace: s,
|
||||||
|
);
|
||||||
|
|
||||||
|
return _transcodeToPng(screenshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List?> _transcodeToPng(Uint8List bytes) async {
|
||||||
|
final codec = await instantiateImageCodec(bytes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final frameInfo = await codec.getNextFrame();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final png = await frameInfo.image.toByteData(format: ImageByteFormat.png);
|
||||||
|
return png?.buffer.asUint8List();
|
||||||
|
} finally {
|
||||||
|
frameInfo.image.dispose();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
codec.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool _isLikelySvg(Uint8List bytes) {
|
bool _isLikelySvg(Uint8List bytes) {
|
||||||
final prefix = utf8
|
final prefix = utf8
|
||||||
.decode(bytes.sublist(0, min(bytes.length, 512)), allowMalformed: true)
|
.decode(bytes.sublist(0, min(bytes.length, 512)), allowMalformed: true)
|
||||||
@@ -110,7 +161,7 @@ bool _isLikelySvg(Uint8List bytes) {
|
|||||||
|
|
||||||
Future<EquatableImage?> _tryDecodeSvg(
|
Future<EquatableImage?> _tryDecodeSvg(
|
||||||
Uint8List bytes, {
|
Uint8List bytes, {
|
||||||
required int hash,
|
required ImageIdentity identity,
|
||||||
int? targetWidth,
|
int? targetWidth,
|
||||||
int? targetHeight,
|
int? targetHeight,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -153,7 +204,7 @@ Future<EquatableImage?> _tryDecodeSvg(
|
|||||||
dimensions.width,
|
dimensions.width,
|
||||||
dimensions.height,
|
dimensions.height,
|
||||||
);
|
);
|
||||||
return EquatableImage(rasterized, hash: hash);
|
return EquatableImage(rasterized, identity: identity);
|
||||||
} finally {
|
} finally {
|
||||||
scaledPicture.dispose();
|
scaledPicture.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,20 @@ part 'singbox_proxy_logs.g.dart';
|
|||||||
/// is well within budget for a debugging surface.
|
/// is well within budget for a debugging surface.
|
||||||
const int _ringBufferCapacity = 2000;
|
const int _ringBufferCapacity = 2000;
|
||||||
|
|
||||||
|
/// Lower bound between two published snapshots. A chatty proxy can emit lines
|
||||||
|
/// far faster than the screen can render them, and every publication copies the
|
||||||
|
/// whole ring buffer, so coalescing bursts is free in terms of what the user
|
||||||
|
/// actually sees.
|
||||||
|
const _publishInterval = Duration(milliseconds: 100);
|
||||||
|
|
||||||
/// Snapshot of buffered log entries. Most-recent-last (chronological).
|
/// Snapshot of buffered log entries. Most-recent-last (chronological).
|
||||||
|
///
|
||||||
|
/// This notifier is `keepAlive` and subscribed from app start (see
|
||||||
|
/// `main.dart`) so startup messages are retained even before any UI mounts.
|
||||||
|
/// Appending and *publishing* are therefore deliberately decoupled: lines
|
||||||
|
/// always land in [_buffer], but a new immutable snapshot is only produced
|
||||||
|
/// while the log screen is on screen ([setLivePublishing]) and at most once
|
||||||
|
/// per [_publishInterval].
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class SingboxProxyLogs extends _$SingboxProxyLogs {
|
class SingboxProxyLogs extends _$SingboxProxyLogs {
|
||||||
final _buffer = Queue<ProxyLogMessage>();
|
final _buffer = Queue<ProxyLogMessage>();
|
||||||
@@ -41,6 +54,28 @@ class SingboxProxyLogs extends _$SingboxProxyLogs {
|
|||||||
StreamSubscription<SingboxProxyLogMessage>? _singboxSubscription;
|
StreamSubscription<SingboxProxyLogMessage>? _singboxSubscription;
|
||||||
StreamSubscription<TorLogMessage>? _torSubscription;
|
StreamSubscription<TorLogMessage>? _torSubscription;
|
||||||
|
|
||||||
|
Timer? _publishTimer;
|
||||||
|
bool _livePublishing = false;
|
||||||
|
|
||||||
|
/// Buffer contents materialized on demand, regardless of publication state.
|
||||||
|
/// Lets a freshly mounted screen paint the backlog without waiting for the
|
||||||
|
/// first throttled snapshot.
|
||||||
|
List<ProxyLogMessage> get snapshot => List.unmodifiable(_buffer);
|
||||||
|
|
||||||
|
/// Enables/disables snapshot publication. Called by the log screen as it
|
||||||
|
/// mounts and unmounts; flushes on both edges so the state left behind is
|
||||||
|
/// always complete.
|
||||||
|
void setLivePublishing(bool enabled) {
|
||||||
|
if (_livePublishing == enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_livePublishing = enabled;
|
||||||
|
_publishTimer?.cancel();
|
||||||
|
_publishTimer = null;
|
||||||
|
_publish();
|
||||||
|
}
|
||||||
|
|
||||||
void _append(ProxyLogMessage message) {
|
void _append(ProxyLogMessage message) {
|
||||||
_buffer.add(message);
|
_buffer.add(message);
|
||||||
|
|
||||||
@@ -48,11 +83,27 @@ class SingboxProxyLogs extends _$SingboxProxyLogs {
|
|||||||
_buffer.removeFirst();
|
_buffer.removeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
state = List.unmodifiable(_buffer);
|
if (!_livePublishing || (_publishTimer?.isActive ?? false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_publishTimer = Timer(_publishInterval, _publish);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _publish() {
|
||||||
|
_publishTimer = null;
|
||||||
|
|
||||||
|
if (!ref.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
_buffer.clear();
|
_buffer.clear();
|
||||||
|
_publishTimer?.cancel();
|
||||||
|
_publishTimer = null;
|
||||||
state = const [];
|
state = const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +120,11 @@ class SingboxProxyLogs extends _$SingboxProxyLogs {
|
|||||||
);
|
);
|
||||||
|
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
|
_publishTimer?.cancel();
|
||||||
unawaited(_singboxSubscription?.cancel());
|
unawaited(_singboxSubscription?.cancel());
|
||||||
unawaited(_torSubscription?.cancel());
|
unawaited(_torSubscription?.cancel());
|
||||||
});
|
});
|
||||||
|
|
||||||
return List.unmodifiable(_buffer);
|
return snapshot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,26 @@ class SingboxProxyLogsScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final logs = ref.watch(singboxProxyLogsProvider);
|
// The ring buffer fills from app start but only publishes snapshots while
|
||||||
|
// this screen is up, so opt in for as long as we're mounted. Seeded from
|
||||||
|
// the notifier's live buffer during the first build, so the backlog is
|
||||||
|
// painted immediately rather than after the first published snapshot.
|
||||||
|
final logsState = useState(
|
||||||
|
useMemoized(() => ref.read(singboxProxyLogsProvider.notifier).snapshot),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
final notifier = ref.read(singboxProxyLogsProvider.notifier);
|
||||||
|
notifier.setLivePublishing(true);
|
||||||
|
|
||||||
|
return () => notifier.setLivePublishing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
ref.listen(singboxProxyLogsProvider, (previous, next) {
|
||||||
|
logsState.value = next;
|
||||||
|
});
|
||||||
|
|
||||||
|
final logs = logsState.value;
|
||||||
final filter = useState<String?>(null);
|
final filter = useState<String?>(null);
|
||||||
final autoScroll = useState(true);
|
final autoScroll = useState(true);
|
||||||
final scrollController = useScrollController();
|
final scrollController = useScrollController();
|
||||||
@@ -71,9 +90,14 @@ class SingboxProxyLogsScreen extends HookConsumerWidget {
|
|||||||
return () => scrollController.removeListener(onScroll);
|
return () => scrollController.removeListener(onScroll);
|
||||||
}, [scrollController]);
|
}, [scrollController]);
|
||||||
|
|
||||||
final filtered = filter.value == null
|
// Memoized so scroll-driven rebuilds (autoScroll flipping) don't re-scan
|
||||||
? logs
|
// up to 2000 entries; only a new snapshot or a filter change does.
|
||||||
: logs.where((m) => m.level.toLowerCase() == filter.value).toList();
|
final filtered = useMemoized(
|
||||||
|
() => filter.value == null
|
||||||
|
? logs
|
||||||
|
: logs.where((m) => m.level.toLowerCase() == filter.value).toList(),
|
||||||
|
[logs, filter.value],
|
||||||
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|||||||
+21
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||||
as fmc;
|
as fmc;
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
@@ -76,6 +77,26 @@ CaptureTabData? sandboxCaptureForTab(Ref ref, {required String? tabId}) {
|
|||||||
return map?[tabId];
|
return map?[tabId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Canonical source URLs of sandbox-captured tabs, keyed by tabId.
|
||||||
|
///
|
||||||
|
/// [sandboxCaptureMapProvider] is a drift stream over the whole capture-tab
|
||||||
|
/// table, so it re-emits a fresh map (and a fresh `CaptureTabData` per row) on
|
||||||
|
/// any write to that table — including ones that touch columns nothing on
|
||||||
|
/// screen renders. Consumers that build a tab-keyed list and only need the
|
||||||
|
/// source URL watch this instead, so an unrelated write can't rebuild the
|
||||||
|
/// always-visible quick tab switcher or the tab-preview list.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
EquatableValue<Map<String, Uri>> sandboxSourceUris(Ref ref) {
|
||||||
|
final rows = ref.watch(sandboxCaptureMapProvider).value ?? const {};
|
||||||
|
|
||||||
|
// Equatable result, so a re-emit that doesn't change any source URL stops
|
||||||
|
// here instead of propagating.
|
||||||
|
return EquatableValue({
|
||||||
|
for (final MapEntry(:key, :value) in rows.entries)
|
||||||
|
if (parseSandboxSource(value) case final uri?) key: uri,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// The canonical source URL of a sandbox-captured tab, or `null` when the
|
/// The canonical source URL of a sandbox-captured tab, or `null` when the
|
||||||
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
|
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
|
||||||
/// that case).
|
/// that case).
|
||||||
|
|||||||
+75
@@ -188,6 +188,81 @@ final class SandboxCaptureForTabFamily extends $Family
|
|||||||
String toString() => r'sandboxCaptureForTabProvider';
|
String toString() => r'sandboxCaptureForTabProvider';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Canonical source URLs of sandbox-captured tabs, keyed by tabId.
|
||||||
|
///
|
||||||
|
/// [sandboxCaptureMapProvider] is a drift stream over the whole capture-tab
|
||||||
|
/// table, so it re-emits a fresh map (and a fresh `CaptureTabData` per row) on
|
||||||
|
/// any write to that table — including ones that touch columns nothing on
|
||||||
|
/// screen renders. Consumers that build a tab-keyed list and only need the
|
||||||
|
/// source URL watch this instead, so an unrelated write can't rebuild the
|
||||||
|
/// always-visible quick tab switcher or the tab-preview list.
|
||||||
|
|
||||||
|
@ProviderFor(sandboxSourceUris)
|
||||||
|
final sandboxSourceUrisProvider = SandboxSourceUrisProvider._();
|
||||||
|
|
||||||
|
/// Canonical source URLs of sandbox-captured tabs, keyed by tabId.
|
||||||
|
///
|
||||||
|
/// [sandboxCaptureMapProvider] is a drift stream over the whole capture-tab
|
||||||
|
/// table, so it re-emits a fresh map (and a fresh `CaptureTabData` per row) on
|
||||||
|
/// any write to that table — including ones that touch columns nothing on
|
||||||
|
/// screen renders. Consumers that build a tab-keyed list and only need the
|
||||||
|
/// source URL watch this instead, so an unrelated write can't rebuild the
|
||||||
|
/// always-visible quick tab switcher or the tab-preview list.
|
||||||
|
|
||||||
|
final class SandboxSourceUrisProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
EquatableValue<Map<String, Uri>>,
|
||||||
|
EquatableValue<Map<String, Uri>>,
|
||||||
|
EquatableValue<Map<String, Uri>>
|
||||||
|
>
|
||||||
|
with $Provider<EquatableValue<Map<String, Uri>>> {
|
||||||
|
/// Canonical source URLs of sandbox-captured tabs, keyed by tabId.
|
||||||
|
///
|
||||||
|
/// [sandboxCaptureMapProvider] is a drift stream over the whole capture-tab
|
||||||
|
/// table, so it re-emits a fresh map (and a fresh `CaptureTabData` per row) on
|
||||||
|
/// any write to that table — including ones that touch columns nothing on
|
||||||
|
/// screen renders. Consumers that build a tab-keyed list and only need the
|
||||||
|
/// source URL watch this instead, so an unrelated write can't rebuild the
|
||||||
|
/// always-visible quick tab switcher or the tab-preview list.
|
||||||
|
SandboxSourceUrisProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'sandboxSourceUrisProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$sandboxSourceUrisHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<EquatableValue<Map<String, Uri>>> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
EquatableValue<Map<String, Uri>> create(Ref ref) {
|
||||||
|
return sandboxSourceUris(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(EquatableValue<Map<String, Uri>> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<EquatableValue<Map<String, Uri>>>(
|
||||||
|
value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$sandboxSourceUrisHash() => r'2d04e6e0e79c5b79e640386fe29e101fdd64688d';
|
||||||
|
|
||||||
/// The canonical source URL of a sandbox-captured tab, or `null` when the
|
/// The canonical source URL of a sandbox-captured tab, or `null` when the
|
||||||
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
|
/// tab is not a sandbox capture (the regular `tabState.url` should be used in
|
||||||
/// that case).
|
/// that case).
|
||||||
|
|||||||
@@ -3,30 +3,106 @@ import 'dart:typed_data';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:weblibre/domain/entities/equatable_image.dart';
|
||||||
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
|
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('tryDecodeImage rasterizes svg bytes at favicon size', (
|
testWidgets('tryDecodeImage rasterizes svg bytes at favicon size', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
clearImageCache();
|
// Must run outside the fake-async zone: flutter_svg's loader parses the
|
||||||
final svgBytes = Uint8List.fromList(utf8.encode(_svgIcon));
|
// document via `compute()`, i.e. a real isolate, and that future never
|
||||||
|
// completes while `testWidgets` controls the clock — the test would hang
|
||||||
|
// forever rather than fail.
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
clearImageCache();
|
||||||
|
final svgBytes = Uint8List.fromList(utf8.encode(_svgIcon));
|
||||||
|
|
||||||
final image = await tryDecodeImage(svgBytes);
|
final image = await tryDecodeImage(svgBytes);
|
||||||
|
|
||||||
expect(image, isNotNull);
|
expect(image, isNotNull);
|
||||||
expect(image!.value, isNotNull);
|
expect(image!.value, isNotNull);
|
||||||
expect(image.value!.width, 32);
|
expect(image.value!.width, 32);
|
||||||
expect(image.value!.height, 32);
|
expect(image.value!.height, 32);
|
||||||
|
|
||||||
final byteData = await image.value!.toByteData(
|
final byteData = await image.value!.toByteData(
|
||||||
format: ImageByteFormat.rawRgba,
|
format: ImageByteFormat.rawRgba,
|
||||||
|
);
|
||||||
|
expect(byteData, isNotNull);
|
||||||
|
|
||||||
|
// A pixel in the right half should be painted once the SVG is scaled
|
||||||
|
// to the requested raster size instead of being left in the top-left.
|
||||||
|
expect(_rgbaAt(byteData!, width: 32, x: 24, y: 16), [47, 128, 237, 255]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tryDecodeImage does not serve a cached decode across sizes', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
clearImageCache();
|
||||||
|
final svgBytes = Uint8List.fromList(utf8.encode(_svgIcon));
|
||||||
|
|
||||||
|
// Same bytes, different decode options. The cache is keyed on the digest
|
||||||
|
// *and* the options, so the second call must not be answered with the
|
||||||
|
// first call's differently-sized image.
|
||||||
|
final sized = await tryDecodeImage(svgBytes, targetWidth: 64);
|
||||||
|
expect(sized?.value?.width, 64);
|
||||||
|
expect(sized?.value?.height, 64);
|
||||||
|
|
||||||
|
final unsized = await tryDecodeImage(svgBytes);
|
||||||
|
expect(unsized?.value?.width, 32);
|
||||||
|
expect(unsized?.value?.height, 32);
|
||||||
|
|
||||||
|
// Reversed order, to catch a cache that only poisons one direction.
|
||||||
|
clearImageCache();
|
||||||
|
|
||||||
|
final unsizedFirst = await tryDecodeImage(svgBytes);
|
||||||
|
expect(unsizedFirst?.value?.width, 32);
|
||||||
|
|
||||||
|
final sizedSecond = await tryDecodeImage(svgBytes, targetWidth: 64);
|
||||||
|
expect(sizedSecond?.value?.width, 64);
|
||||||
|
|
||||||
|
// Two decodes that differ only in size must not compare equal, or the
|
||||||
|
// `state[id] == next` guards in the tab-state notifiers would drop a
|
||||||
|
// genuine change.
|
||||||
|
expect(sizedSecond, isNot(equals(unsizedFirst)));
|
||||||
|
|
||||||
|
// ...while a repeat of the *same* request is still deduped.
|
||||||
|
final unsizedAgain = await tryDecodeImage(svgBytes);
|
||||||
|
expect(unsizedAgain, equals(unsizedFirst));
|
||||||
|
expect(identical(unsizedAgain, unsizedFirst), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ImageIdentity compares structurally, not by a folded hash', () {
|
||||||
|
const a = (
|
||||||
|
digest: 0x0123456789ABCDEF,
|
||||||
|
targetWidth: 720,
|
||||||
|
targetHeight: null,
|
||||||
|
allowUpscaling: false,
|
||||||
|
);
|
||||||
|
// Differs only in the low bits of the 64-bit content digest. Folding the
|
||||||
|
// identity into a single hash code would truncate to 30 bits and could
|
||||||
|
// collapse these two onto the same cache entry; structural equality can't.
|
||||||
|
const b = (
|
||||||
|
digest: 0x0123456789ABCDEE,
|
||||||
|
targetWidth: 720,
|
||||||
|
targetHeight: null,
|
||||||
|
allowUpscaling: false,
|
||||||
);
|
);
|
||||||
expect(byteData, isNotNull);
|
|
||||||
|
|
||||||
// A pixel in the right half should be painted once the SVG is scaled
|
expect(a, isNot(equals(b)));
|
||||||
// to the requested raster size instead of being left in the top-left.
|
expect(<ImageIdentity, String>{a: 'a', b: 'b'}.length, 2);
|
||||||
expect(_rgbaAt(byteData!, width: 32, x: 24, y: 16), [47, 128, 237, 255]);
|
|
||||||
|
// Options are part of the key too.
|
||||||
|
const sameDigestOtherWidth = (
|
||||||
|
digest: 0x0123456789ABCDEF,
|
||||||
|
targetWidth: 32,
|
||||||
|
targetHeight: null,
|
||||||
|
allowUpscaling: false,
|
||||||
|
);
|
||||||
|
expect(a, isNot(equals(sameDigestOtherWidth)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user