prepare for multiple apps

This commit is contained in:
Fabian Freund
2026-04-06 12:23:11 +02:00
parent bd1600e8dc
commit 5afc323f04
904 changed files with 29 additions and 29 deletions
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/data/models/web_page_info.dart';
import 'package:weblibre/domain/services/generic_website.dart';
import 'package:weblibre/extensions/ref_cache.dart';
import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/features/user/data/models/tor_settings.dart';
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
part 'website_title.g.dart';
@Riverpod()
class CompletePageInfo extends _$CompletePageInfo {
@override
AsyncValue<WebPageInfo> build(TabState cached) {
ref.cacheFor(const Duration(minutes: 2));
if (cached.isPageInfoComplete || !cached.url.isHttpOrHttps) {
return AsyncData(cached);
}
ref.listen(
fireImmediately: true,
tabStateProvider(cached.id).select((value) => value?.title),
(previous, next) {
if (next != null) {
final current = stateOrNull?.value ?? cached;
state = AsyncData(current.copyWith.title(next));
}
},
);
ref.listen(
fireImmediately: true,
tabStateProvider(cached.id).select((value) => value?.favicon),
(previous, next) {
if (next != null) {
final current = stateOrNull?.value ?? cached;
state = AsyncData(current.copyWith.favicon(next));
}
},
);
return AsyncData(cached);
}
}
@Riverpod()
Future<WebPageInfo> pageInfo(
Ref ref,
Uri url, {
required bool isImageRequest,
}) async {
final link = ref.cacheFor(const Duration(minutes: 2));
final tabState = ref.read(selectedTabStateProvider);
int? proxyPort;
if (tabState?.id != null) {
final containerData = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerData(tabState!.id);
final torSettings = ref.read(torSettingsWithDefaultsProvider);
if (containerData?.metadata.useProxy == true ||
(tabState.tabMode is! PrivateTabMode &&
torSettings.proxyRegularTabsMode == TorRegularTabProxyMode.all) ||
(tabState.tabMode is PrivateTabMode &&
torSettings.proxyPrivateTabsTor)) {
proxyPort = await ref.read(
torProxyServiceProvider.selectAsync((value) => value.socksPort),
);
if (proxyPort == null) {
throw Exception('Could not proxy request');
}
}
}
final result = await ref
.watch(genericWebsiteServiceProvider.notifier)
.fetchPageInfo(
url: url,
isImageRequest: isImageRequest,
proxyPort: proxyPort,
);
if (!result.isSuccess) {
link.close();
}
return result.value;
}
@Riverpod()
AsyncValue<EquatableValue<Set<Uri>?>> websiteFeedProvider(
Ref ref,
String tabId,
) {
final tabState = ref.watch(tabStateProvider(tabId))!;
final feeds = ref.watch(
pageInfoProvider(
tabState.url,
isImageRequest: false,
).select((value) => value.whenData((data) => EquatableValue(data.feeds))),
);
return feeds;
}
@@ -0,0 +1,279 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'website_title.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(CompletePageInfo)
final completePageInfoProvider = CompletePageInfoFamily._();
final class CompletePageInfoProvider
extends $NotifierProvider<CompletePageInfo, AsyncValue<WebPageInfo>> {
CompletePageInfoProvider._({
required CompletePageInfoFamily super.from,
required TabState super.argument,
}) : super(
retry: null,
name: r'completePageInfoProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$completePageInfoHash();
@override
String toString() {
return r'completePageInfoProvider'
''
'($argument)';
}
@$internal
@override
CompletePageInfo create() => CompletePageInfo();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<WebPageInfo> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<WebPageInfo>>(value),
);
}
@override
bool operator ==(Object other) {
return other is CompletePageInfoProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$completePageInfoHash() => r'4af0e7c06f07e95aa7e0037782e1a7e661ee61d1';
final class CompletePageInfoFamily extends $Family
with
$ClassFamilyOverride<
CompletePageInfo,
AsyncValue<WebPageInfo>,
AsyncValue<WebPageInfo>,
AsyncValue<WebPageInfo>,
TabState
> {
CompletePageInfoFamily._()
: super(
retry: null,
name: r'completePageInfoProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
CompletePageInfoProvider call(TabState cached) =>
CompletePageInfoProvider._(argument: cached, from: this);
@override
String toString() => r'completePageInfoProvider';
}
abstract class _$CompletePageInfo extends $Notifier<AsyncValue<WebPageInfo>> {
late final _$args = ref.$arg as TabState;
TabState get cached => _$args;
AsyncValue<WebPageInfo> build(TabState cached);
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<AsyncValue<WebPageInfo>, AsyncValue<WebPageInfo>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<WebPageInfo>, AsyncValue<WebPageInfo>>,
AsyncValue<WebPageInfo>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@ProviderFor(pageInfo)
final pageInfoProvider = PageInfoFamily._();
final class PageInfoProvider
extends
$FunctionalProvider<
AsyncValue<WebPageInfo>,
WebPageInfo,
FutureOr<WebPageInfo>
>
with $FutureModifier<WebPageInfo>, $FutureProvider<WebPageInfo> {
PageInfoProvider._({
required PageInfoFamily super.from,
required (Uri, {bool isImageRequest}) super.argument,
}) : super(
retry: null,
name: r'pageInfoProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pageInfoHash();
@override
String toString() {
return r'pageInfoProvider'
''
'$argument';
}
@$internal
@override
$FutureProviderElement<WebPageInfo> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<WebPageInfo> create(Ref ref) {
final argument = this.argument as (Uri, {bool isImageRequest});
return pageInfo(ref, argument.$1, isImageRequest: argument.isImageRequest);
}
@override
bool operator ==(Object other) {
return other is PageInfoProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$pageInfoHash() => r'9a379657c8a3f353aba1dfb0f66b9100ce494fc5';
final class PageInfoFamily extends $Family
with
$FunctionalFamilyOverride<
FutureOr<WebPageInfo>,
(Uri, {bool isImageRequest})
> {
PageInfoFamily._()
: super(
retry: null,
name: r'pageInfoProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
PageInfoProvider call(Uri url, {required bool isImageRequest}) =>
PageInfoProvider._(
argument: (url, isImageRequest: isImageRequest),
from: this,
);
@override
String toString() => r'pageInfoProvider';
}
@ProviderFor(websiteFeedProvider)
final websiteFeedProviderProvider = WebsiteFeedProviderFamily._();
final class WebsiteFeedProviderProvider
extends
$FunctionalProvider<
AsyncValue<EquatableValue<Set<Uri>?>>,
AsyncValue<EquatableValue<Set<Uri>?>>,
AsyncValue<EquatableValue<Set<Uri>?>>
>
with $Provider<AsyncValue<EquatableValue<Set<Uri>?>>> {
WebsiteFeedProviderProvider._({
required WebsiteFeedProviderFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'websiteFeedProviderProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$websiteFeedProviderHash();
@override
String toString() {
return r'websiteFeedProviderProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<AsyncValue<EquatableValue<Set<Uri>?>>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
AsyncValue<EquatableValue<Set<Uri>?>> create(Ref ref) {
final argument = this.argument as String;
return websiteFeedProvider(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<EquatableValue<Set<Uri>?>> value) {
return $ProviderOverride(
origin: this,
providerOverride:
$SyncValueProvider<AsyncValue<EquatableValue<Set<Uri>?>>>(value),
);
}
@override
bool operator ==(Object other) {
return other is WebsiteFeedProviderProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$websiteFeedProviderHash() =>
r'b8ad81b883acee41f420af9aeca254fbd29b3107';
final class WebsiteFeedProviderFamily extends $Family
with
$FunctionalFamilyOverride<
AsyncValue<EquatableValue<Set<Uri>?>>,
String
> {
WebsiteFeedProviderFamily._()
: super(
retry: null,
name: r'websiteFeedProviderProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
WebsiteFeedProviderProvider call(String tabId) =>
WebsiteFeedProviderProvider._(argument: tabId, from: this);
@override
String toString() => r'websiteFeedProviderProvider';
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/widgets.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
AsyncSnapshot<T> useCachedFuture<T>(
Future<T> Function() valueBuilder, [
List<Object?> keys = const <Object>[],
]) {
// ignore: discarded_futures is used
final cachedFuture = useMemoized(valueBuilder, keys);
return useFuture(cachedFuture);
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:weblibre/utils/debouncer.dart';
/// A hook that creates a [Debouncer] with automatic disposal.
///
/// The debouncer will be created once and automatically disposed when the
/// widget is unmounted.
///
/// Example:
/// ```dart
/// final debouncer = useDebouncer(const Duration(milliseconds: 300));
///
/// // Use the debouncer
/// debouncer.eventOccured(() {
/// // Your debounced action
/// });
/// ```
Debouncer useDebouncer(Duration duration) {
final debouncer = useMemoized(() => Debouncer(duration));
useEffect(() => debouncer.dispose, [debouncer]);
return debouncer;
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
MenuController useMenuController() {
return useMemoized(() => MenuController());
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/foundation.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
void useOnDispose(VoidCallback onDispose) {
useEffect(() {
return onDispose;
}, const []);
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
void useOnInitialization(FutureOr<void> Function() callback) {
useEffect(() {
unawaited(Future(callback));
return null;
}, []);
}
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Adds a given [listener] to a [Listenable] and removes it when the hook is
/// disposed. The listener is only called when the [selector] result changes.
///
/// As opposed to `useListenable`, this hook does not mark the widget as needing
/// build when the listener is called. Use this for side effects that do not
/// require a rebuild.
///
/// See also:
/// * [Listenable]
/// * [ValueListenable]
/// * [useOnListenableChange]
void useOnListenableChangeSelector<R>(
Listenable? listenable,
R Function() selector,
VoidCallback listener,
) {
return use(_OnListenableChangeSelectorHook(listenable, selector, listener));
}
class _OnListenableChangeSelectorHook<R> extends Hook<void> {
const _OnListenableChangeSelectorHook(
this.listenable,
this.selector,
this.listener,
);
final Listenable? listenable;
final R Function() selector;
final VoidCallback listener;
@override
_OnListenableChangeSelectorHookState<R> createState() =>
_OnListenableChangeSelectorHookState<R>();
}
class _OnListenableChangeSelectorHookState<R>
extends HookState<void, _OnListenableChangeSelectorHook<R>> {
late R _selectorResult = hook.selector();
@override
void initHook() {
super.initHook();
hook.listenable?.addListener(_listener);
}
@override
void didUpdateHook(_OnListenableChangeSelectorHook<R> oldHook) {
super.didUpdateHook(oldHook);
if (hook.selector != oldHook.selector) {
_selectorResult = hook.selector();
}
if (hook.listenable != oldHook.listenable) {
oldHook.listenable?.removeListener(_listener);
hook.listenable?.addListener(_listener);
_selectorResult = hook.selector();
}
}
@override
void build(BuildContext context) {}
void _listener() {
final latestSelectorResult = hook.selector();
if (_selectorResult != latestSelectorResult) {
_selectorResult = latestSelectorResult;
hook.listener();
}
}
@override
void dispose() {
hook.listenable?.removeListener(_listener);
}
@override
String get debugLabel => 'useOnListenableChangeSelector<$R>';
@override
Object? get debugValue => hook.listenable;
}
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/widgets.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:weblibre/utils/sampled_value_notifier.dart';
/// A custom hook that creates a sampled ValueNotifier from another ValueNotifier
ValueNotifier<T> useSampledValueNotifier<T>({
required ValueNotifier<T> source,
required Duration sampleDuration,
}) {
return use(
_SampledValueNotifierHook<T>(
source: source,
sampleDuration: sampleDuration,
),
);
}
/// Hook implementation for sampled ValueNotifier
class _SampledValueNotifierHook<T> extends Hook<ValueNotifier<T>> {
final ValueNotifier<T> source;
final Duration sampleDuration;
const _SampledValueNotifierHook({
required this.source,
required this.sampleDuration,
});
@override
_SampledValueNotifierHookState<T> createState() =>
_SampledValueNotifierHookState<T>();
}
class _SampledValueNotifierHookState<T>
extends HookState<ValueNotifier<T>, _SampledValueNotifierHook<T>> {
late SampledValueNotifier<T> _sampledNotifier;
@override
void initHook() {
super.initHook();
_sampledNotifier = SampledValueNotifier<T>(
source: hook.source,
sampleDuration: hook.sampleDuration,
);
}
@override
ValueNotifier<T> build(BuildContext context) {
return _sampledNotifier;
}
@override
void dispose() {
_sampledNotifier.dispose();
super.dispose();
}
@override
String get debugLabel => 'useSampledValueNotifier<$T>';
}
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// A hook that manages visibility state based on scroll direction.
///
/// Returns a [ValueNotifier<bool>] that automatically updates based on scroll behavior:
/// - Hides when scrolling down beyond [hideThreshold]
/// - Shows when scrolling up beyond [showThreshold] or at the top of the scroll view
/// - Ignores scroll events during [initializationDelay] to prevent hiding during jumpTo/animateTo
///
/// Example:
/// ```dart
/// final scrollController = useScrollController();
/// final isVisible = useScrollVisibility(scrollController);
///
/// return AnimatedOpacity(
/// opacity: isVisible.value ? 1.0 : 0.0,
/// child: FloatingActionButton(...),
/// );
/// ```
ValueNotifier<bool> useScrollVisibility(
ScrollController scrollController, {
double hideThreshold = 5.0,
double showThreshold = 5.0,
Duration initializationDelay = const Duration(milliseconds: 1000),
bool disableAnimations = false,
}) {
final isVisible = useState(true);
final lastScrollOffset = useRef(0.0);
final isInitialized = useRef(false);
useEffect(
() {
// Start timer to enable scroll listener after initialization delay
final timer = Timer(
disableAnimations ? Duration.zero : initializationDelay,
() {
if (scrollController.hasClients) {
lastScrollOffset.value = scrollController.offset;
}
isInitialized.value = true;
},
);
void scrollListener() {
// Ignore scroll events until initialization is complete
if (!isInitialized.value) return;
final currentOffset = scrollController.offset;
final difference = currentOffset - lastScrollOffset.value;
// Hide when scrolling down beyond threshold
if (difference > hideThreshold && isVisible.value) {
isVisible.value = false;
}
// Show when scrolling up beyond threshold or at top
else if ((difference < -showThreshold || currentOffset <= 0) &&
!isVisible.value) {
isVisible.value = true;
}
lastScrollOffset.value = currentOffset;
}
scrollController.addListener(scrollListener);
return () {
timer.cancel();
scrollController.removeListener(scrollListener);
};
},
[
scrollController,
hideThreshold,
showThreshold,
initializationDelay,
disableAnimations,
],
);
return isVisible;
}
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
void useSyncPageWithTab(
TabController tabController,
PageController pageController, {
void Function(int index)? onIndexChanged,
bool disableAnimations = false,
}) {
useEffect(() {
Future<void> syncPage() async {
if (disableAnimations) {
pageController.jumpToPage(tabController.index);
} else {
await pageController.animateToPage(
tabController.index,
curve: Curves.linear,
duration: const Duration(milliseconds: 300),
);
}
onIndexChanged?.call(tabController.index);
}
void syncTab() {
if (!tabController.indexIsChanging) {
final index = pageController.page!.round();
tabController.animateTo(
index,
duration: disableAnimations ? Duration.zero : kTabScrollDuration,
);
onIndexChanged?.call(index);
}
}
tabController.addListener(syncPage);
pageController.addListener(syncTab);
return () {
tabController.removeListener(syncPage);
pageController.removeListener(syncTab);
};
}, [tabController, pageController, disableAnimations]);
}
@@ -0,0 +1,113 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/widgets.dart';
abstract class TorIcons {
static const String _fontFamily = 'TorIcons';
static const IconData authority = IconData(0xf101, fontFamily: _fontFamily);
static const IconData badexit = IconData(0xf102, fontFamily: _fontFamily);
static const IconData bridge = IconData(0xf103, fontFamily: _fontFamily);
static const IconData country = IconData(0xf104, fontFamily: _fontFamily);
static const IconData directory = IconData(0xf105, fontFamily: _fontFamily);
static const IconData exit = IconData(0xf106, fontFamily: _fontFamily);
static const IconData experimental = IconData(
0xf107,
fontFamily: _fontFamily,
);
static const IconData fallbackdir = IconData(0xf108, fontFamily: _fontFamily);
static const IconData fast = IconData(0xf109, fontFamily: _fontFamily);
static const IconData fingerprint = IconData(0xf10a, fontFamily: _fontFamily);
static const IconData guard = IconData(0xf10b, fontFamily: _fontFamily);
static const IconData hibernating = IconData(0xf10c, fontFamily: _fontFamily);
static const IconData hsdir = IconData(0xf10d, fontFamily: _fontFamily);
static const IconData ipv4 = IconData(0xf10e, fontFamily: _fontFamily);
static const IconData ipv6exit = IconData(0xf10f, fontFamily: _fontFamily);
static const IconData ipv6 = IconData(0xf110, fontFamily: _fontFamily);
static const IconData noedconsensus = IconData(
0xf111,
fontFamily: _fontFamily,
);
static const IconData notrecommended = IconData(
0xf112,
fontFamily: _fontFamily,
);
static const IconData onionAlt = IconData(0xf113, fontFamily: _fontFamily);
static const IconData onion = IconData(0xf114, fontFamily: _fontFamily);
static const IconData outdated = IconData(0xf115, fontFamily: _fontFamily);
static const IconData reachableipv4 = IconData(
0xf116,
fontFamily: _fontFamily,
);
static const IconData reachableipv6 = IconData(
0xf117,
fontFamily: _fontFamily,
);
static const IconData relay = IconData(0xf118, fontFamily: _fontFamily);
static const IconData running = IconData(0xf119, fontFamily: _fontFamily);
static const IconData stable = IconData(0xf11a, fontFamily: _fontFamily);
static const IconData tshirt = IconData(0xf11b, fontFamily: _fontFamily);
static const IconData unmeasured = IconData(0xf11c, fontFamily: _fontFamily);
static const IconData unreachableipv4 = IconData(
0xf11d,
fontFamily: _fontFamily,
);
static const IconData unreachableipv6 = IconData(
0xf11e,
fontFamily: _fontFamily,
);
static const IconData v2dir = IconData(0xf11f, fontFamily: _fontFamily);
static const IconData valid = IconData(0xf120, fontFamily: _fontFamily);
static const List<IconData> values = [
authority,
badexit,
bridge,
country,
directory,
exit,
experimental,
fallbackdir,
fast,
fingerprint,
guard,
hibernating,
hsdir,
ipv4,
ipv6exit,
ipv6,
noedconsensus,
notrecommended,
onionAlt,
onion,
outdated,
reachableipv4,
reachableipv6,
relay,
running,
stable,
tshirt,
unmeasured,
unreachableipv4,
unreachableipv6,
v2dir,
valid,
];
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/widgets.dart';
abstract class WebLibreIcons {
static const String _fontFamily = 'WebLibre';
static const IconData privateTab = IconData(0xEA01, fontFamily: _fontFamily);
static const IconData tabOptions = IconData(0xEA02, fontFamily: _fontFamily);
static const IconData tabType = IconData(0xEA03, fontFamily: _fontFamily);
static const List<IconData> values = [privateTab, tabOptions, tabType];
}
@@ -0,0 +1,260 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/providers/router.dart';
import 'package:weblibre/domain/services/app_initialization.dart';
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class MainApp extends HookConsumerWidget {
final ThemeData? theme;
final ThemeData? darkTheme;
final ThemeMode? themeMode;
final double uiScaleFactor;
final bool disableAnimations;
const MainApp({
required this.theme,
required this.darkTheme,
required this.themeMode,
required this.uiScaleFactor,
required this.disableAnimations,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final initializationResult = ref.watch(appInitializationServiceProvider);
final router = ref.watch(routerProvider);
return initializationResult.fold(
(initializationState) {
if (!initializationState.initialized) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: theme,
darkTheme: darkTheme,
themeMode: themeMode,
themeAnimationStyle: disableAnimations
? AnimationStyle.noAnimation
: null,
builder: (context, child) {
return _AppMediaQueryOverrides(
uiScaleFactor: uiScaleFactor,
disableAnimations: disableAnimations,
child: child ?? const SizedBox.shrink(),
);
},
home: Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
if (initializationState.stage != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(initializationState.stage!),
),
],
),
),
),
);
}
return MaterialApp.router(
debugShowCheckedModeBanner: false,
theme: theme,
darkTheme: darkTheme,
themeMode: themeMode,
themeAnimationStyle: disableAnimations
? AnimationStyle.noAnimation
: null,
routerConfig: router.value,
builder: (context, child) {
return _AppMediaQueryOverrides(
uiScaleFactor: uiScaleFactor,
disableAnimations: disableAnimations,
child: _SyncEventListener(
child: child ?? const SizedBox.shrink(),
),
);
},
);
},
onFailure: (errorMessage) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: theme,
darkTheme: darkTheme,
themeMode: themeMode,
themeAnimationStyle: disableAnimations
? AnimationStyle.noAnimation
: null,
builder: (context, child) {
return _AppMediaQueryOverrides(
uiScaleFactor: uiScaleFactor,
disableAnimations: disableAnimations,
child: child ?? const SizedBox.shrink(),
);
},
home: Scaffold(
appBar: AppBar(title: const Text('Initiallization Error')),
body: Center(
child: FailureWidget(
title: 'Could not initialize App',
exception: errorMessage.toString(),
onRetry: () async {
await ref
.read(appInitializationServiceProvider.notifier)
.reinitialize();
},
),
),
),
);
},
);
}
}
MediaQueryData applyAppMediaQueryOverrides({
required MediaQueryData mediaQuery,
required double uiScaleFactor,
required bool disableAnimations,
}) {
final textScaler = uiScaleFactor == 1.0
? mediaQuery.textScaler
: _AppTextScaler(
baseTextScaler: mediaQuery.textScaler,
uiScaleFactor: uiScaleFactor,
);
if (disableAnimations) {
return mediaQuery.copyWith(textScaler: textScaler, disableAnimations: true);
}
if (uiScaleFactor == 1.0) {
return mediaQuery;
}
return mediaQuery.copyWith(textScaler: textScaler);
}
class _AppMediaQueryOverrides extends StatelessWidget {
final double uiScaleFactor;
final bool disableAnimations;
final Widget child;
const _AppMediaQueryOverrides({
required this.uiScaleFactor,
required this.disableAnimations,
required this.child,
});
@override
Widget build(BuildContext context) {
if (uiScaleFactor == 1.0 && !disableAnimations) {
return child;
}
final mediaQuery = MediaQuery.of(context);
final overriddenMediaQuery = applyAppMediaQueryOverrides(
mediaQuery: mediaQuery,
uiScaleFactor: uiScaleFactor,
disableAnimations: disableAnimations,
);
return MediaQuery(data: overriddenMediaQuery, child: child);
}
}
class _AppTextScaler extends TextScaler {
final TextScaler baseTextScaler;
final double uiScaleFactor;
const _AppTextScaler({
required this.baseTextScaler,
required this.uiScaleFactor,
}) : assert(uiScaleFactor > 0);
@override
double scale(double fontSize) =>
baseTextScaler.scale(fontSize) * uiScaleFactor;
@override
double get textScaleFactor => scale(1.0);
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is _AppTextScaler &&
baseTextScaler == other.baseTextScaler &&
uiScaleFactor == other.uiScaleFactor;
}
@override
int get hashCode => Object.hash(baseTextScaler, uiScaleFactor);
}
class _SyncEventListener extends ConsumerWidget {
final Widget child;
const _SyncEventListener({required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(syncEventProvider, (previous, next) {
if (next.isLoading || !next.hasValue) return;
final event = next.value;
if (event == null) return;
final (syncEvent, syncError) = event;
switch (syncEvent) {
// case SyncEvent.completed:
// ui_helper.showInfoMessage(
// context,
// 'Synchronization complete',
// duration: const Duration(seconds: 2),
// );
case SyncEvent.error:
ui_helper.showErrorMessage(
context,
syncError ?? 'Synchronization failed',
);
case SyncEvent.started:
case SyncEvent.completed:
case null:
break;
}
});
return child;
}
}
@@ -0,0 +1,255 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class AnimateGradientShader extends StatefulWidget {
const AnimateGradientShader({
super.key,
required this.primaryColors,
required this.secondaryColors,
this.child,
this.primaryBegin = Alignment.topLeft,
this.primaryEnd = Alignment.topRight,
this.secondaryBegin = Alignment.bottomLeft,
this.secondaryEnd = Alignment.bottomRight,
this.primaryBeginGeometry,
this.primaryEndGeometry,
this.secondaryBeginGeometry,
this.secondaryEndGeometry,
this.textDirectionForGeometry = TextDirection.ltr,
this.controller,
this.duration = const Duration(seconds: 4),
this.animateAlignments = true,
this.reverse = true,
}) : assert(primaryColors.length >= 2),
assert(primaryColors.length == secondaryColors.length);
/// [controller]: pass this to have a fine control over the [Animation]
final AnimationController? controller;
/// [duration]: Time to switch between [Gradient].
/// By default its value is [Duration(seconds:4)]
final Duration duration;
/// [primaryColors]: These will be the starting colors of the [Animation].
final List<Color> primaryColors;
/// [secondaryColors]: These Colors are those in which the [primaryColors] will transition into.
final List<Color> secondaryColors;
/// [primaryBegin]: This is begin [Alignment] for [primaryColors].
/// By default its value is [Alignment.topLeft]
final Alignment primaryBegin;
/// [primaryBegin]: This is end [Alignment] for [primaryColors].
/// By default its value is [Alignment.topRight]
final Alignment primaryEnd;
/// [secondaryBegin]: This is begin [Alignment] for [secondaryColors].
/// By default its value is [Alignment.bottomLeft]
final Alignment secondaryBegin;
/// [secondaryEnd]: This is end [Alignment] for [secondaryColors].
/// By default its value is [Alignment.bottomRight]
final Alignment secondaryEnd;
/// Alternatively you can use [primaryBeginGeometry] over [primaryBegin] for better control over alignments
/// These are really useful for when you are builing an [rtl] app.
/// [primaryBeginGeometry] will have higher priority than [primaryBegin]
final AlignmentGeometry? primaryBeginGeometry;
/// Alternatively you can use [primaryEndGeometry] over [primaryEnd] for better control over alignments
/// These are really useful for when you are builing an [rtl] app.
/// [primaryEndGeometry] will have higher priority than [primaryEnd]
final AlignmentGeometry? primaryEndGeometry;
/// Alternatively you can use [secondaryBeginGeometry] over [secondaryBegin] for better control over alignments
/// These are really useful for when you are builing an [rtl] app.
/// [secondaryBeginGeometry] will have higher priority than [secondaryBegin]
final AlignmentGeometry? secondaryBeginGeometry;
/// Alternatively you can use [secondaryEndGeometry] over [secondaryEnd] for better control over alignments
/// These are really useful for when you are builing an [rtl] app.
/// [secondaryEndGeometry] will have higher priority than [secondaryEnd]
final AlignmentGeometry? secondaryEndGeometry;
/// This is the [TextDirection] which is gonna be used to resolve [AlignmentGeometry] passed through
/// [primaryBeginGeometry], [primaryEndGeometry], [secondaryBeginGeometry], [secondaryEndGeometry]
final TextDirection textDirectionForGeometry;
/// [animateAlignments]: set to false if you don't want to animate the alignments.
/// This can provide you way cooler animations
final bool animateAlignments;
/// [reverse]: set it to false if you don't want to reverse the animation.
/// using that it will go into one direction only
final bool reverse;
final Widget? child;
@override
State<AnimateGradientShader> createState() => _AnimateGradientShaderState();
}
class _AnimateGradientShaderState extends State<AnimateGradientShader>
with TickerProviderStateMixin {
AnimationController? _controller;
Animation<double>? _animation;
late List<ColorTween> _colorTween;
late AlignmentTween begin;
late AlignmentTween end;
List<Color> primaryColors = [];
List<Color> secondaryColors = [];
bool _disableAnimations = false;
@override
void initState() {
_initialize();
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final disableAnimations = MediaQuery.disableAnimationsOf(context);
if (disableAnimations != _disableAnimations) {
_disableAnimations = disableAnimations;
_setAnimations();
}
}
@override
void didUpdateWidget(AnimateGradientShader oldWidget) {
_initialize();
super.didUpdateWidget(oldWidget);
}
void _initialize() {
primaryColors = widget.primaryColors;
secondaryColors = widget.secondaryColors;
_colorTween = _getColorTweens();
if (widget.animateAlignments) _setAlignmentTweens();
_setAnimations();
}
@override
Widget build(BuildContext context) {
if (_animation == null) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: _animation!,
builder: (BuildContext context, Widget? child) {
final gradient = LinearGradient(
begin: widget.animateAlignments
? begin.evaluate(_animation!)
: widget.primaryBegin,
end: widget.animateAlignments
? end.evaluate(_animation!)
: widget.primaryEnd,
colors: _evaluateColors(_animation!),
);
return ShaderMask(
shaderCallback: (Rect bounds) {
return gradient.createShader(
Rect.fromLTWH(0, 0, bounds.width, bounds.height),
);
},
child: widget.child,
);
},
);
}
List<ColorTween> _getColorTweens() {
if (widget.primaryColors.length != widget.secondaryColors.length) {
throw Exception('primaryColors.length != secondaryColors.length');
}
final List<ColorTween> colorTweens = [];
for (int i = 0; i < primaryColors.length; i++) {
colorTweens.add(
ColorTween(begin: primaryColors[i], end: secondaryColors[i]),
);
}
return colorTweens;
}
List<Color> _evaluateColors(Animation<double> animation) {
final List<Color> colors = [];
for (int i = 0; i < _colorTween.length; i++) {
colors.add(_colorTween[i].evaluate(animation)!);
}
return colors;
}
void _setAlignmentTweens() {
final primaryBeginGeometry = widget.primaryBeginGeometry?.resolve(
widget.textDirectionForGeometry,
);
final primaryEndGeometry = widget.primaryEndGeometry?.resolve(
widget.textDirectionForGeometry,
);
final secondaryBeginGeometry = widget.secondaryBeginGeometry?.resolve(
widget.textDirectionForGeometry,
);
final secondaryEndGeometry = widget.secondaryEndGeometry?.resolve(
widget.textDirectionForGeometry,
);
begin = AlignmentTween(
begin: primaryBeginGeometry ?? widget.primaryBegin,
end: primaryEndGeometry ?? widget.primaryEnd,
);
end = AlignmentTween(
begin: secondaryBeginGeometry ?? widget.secondaryBegin,
end: secondaryEndGeometry ?? widget.secondaryEnd,
);
}
void _setAnimations() {
_controller?.dispose();
_controller =
widget.controller ??
AnimationController(vsync: this, duration: widget.duration);
if (_disableAnimations) {
_controller!.value = 0;
} else {
// ignore: discarded_futures
_controller!.repeat(reverse: widget.reverse);
}
_animation = CurvedAnimation(parent: _controller!, curve: Curves.easeInOut);
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
}
@@ -0,0 +1,365 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
/// An internal representation of a child widget subtree that is a child of
/// the [AnimatedIndexedStack].
///
/// This keeps track of animation controllers, keys, and the child widget.
class _ChildEntry {
_ChildEntry({
required this.key,
required this.primaryController,
required this.secondaryController,
required this.child,
});
/// The key of this entry.
/// This is usually a [GlobalKey] to ensure that children do not lose their state.
final Key key;
/// The animation controller for the child's transition.
final AnimationController primaryController;
/// The (curved) animation being used to drive the transition.
final AnimationController secondaryController;
Widget child;
/// Release the resources used by this object.
///
/// The object is no longer usable after this method is called.
void dispose() {
primaryController.dispose();
secondaryController.dispose();
}
@override
String toString() => 'AnimatedIndexedStackEntry#${shortHash(this)}($child)';
}
enum _ChildAnimationDirection {
primaryForward,
primaryReverse,
secondaryForward,
secondaryReverse,
}
/// A Widget that shows a single child from a list of children.
/// Changing the index will animate the change of widgets according to the [transitionBuilder].
/// Removing the widget at the current index will also animate the change.
///
/// Widgets which are not currently visible will be kept alive until they are removed.
class AnimatedIndexedStack extends StatefulWidget {
const AnimatedIndexedStack({
super.key,
this.index = 0,
this.duration = const Duration(milliseconds: 300),
this.reverse = false,
required this.transitionBuilder,
this.layoutBuilder = defaultLayoutBuilder,
this.children = const [],
});
/// The index of the child to show.
///
/// If this is null, none of the children will be shown.
final int? index;
/// The duration of the transition from the old [child] value to the new one.
final Duration duration;
/// Indicates whether the new [child] will visually appear on top of or
/// underneath the old child.
final bool reverse;
/// A function that wraps a new [child] with a primary and secondary animation
/// set define how the child appears and disappears.
final Widget Function(
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
)
transitionBuilder;
/// A function that lays out all the children in this IndexedStack.
/// This defaults to [PageTransitionSwitcher.defaultLayoutBuilder].
final Widget Function(List<Widget> entries) layoutBuilder;
/// The child widgets of the stack.
/// Only the child at index [index] will be shown.
/// To correctly keep track of the state of child widgets, they must be given unique keys.
final List<Widget> children;
/// The default layout builder for [AnimatedIndexedStack].
/// Contains all the children in a [Stack].
static Widget defaultLayoutBuilder(List<Widget> entries) {
return Stack(alignment: Alignment.center, children: entries);
}
@override
State<AnimatedIndexedStack> createState() => _AnimatedIndexedStackState();
}
class _AnimatedIndexedStackState extends State<AnimatedIndexedStack>
with TickerProviderStateMixin {
/// All entries contained in this Stack.
/// This is built from the children list, but may also contain entries which are animating out.
List<_ChildEntry> _entries = [];
/// The entry which is currently at the top of the stack.
_ChildEntry? _currentEntry;
bool _disableAnimations = false;
Duration get _effectiveDuration =>
_disableAnimations ? Duration.zero : widget.duration;
@override
void initState() {
super.initState();
_updateEntriesList();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final disableAnimations = MediaQuery.disableAnimationsOf(context);
if (disableAnimations != _disableAnimations) {
_disableAnimations = disableAnimations;
for (final entry in _entries) {
entry.primaryController.duration = _effectiveDuration;
entry.secondaryController.duration = _effectiveDuration;
}
}
}
@override
void didUpdateWidget(AnimatedIndexedStack oldWidget) {
super.didUpdateWidget(oldWidget);
_updateEntriesList();
}
/// In place operation to shift a child entry to the end of the list (the visual front).
///
/// If entry is null, this is a no-op.
void _moveToEnd(List<_ChildEntry> entries, _ChildEntry? entry) {
if (entry == null) return;
entries.remove(entry);
entries.add(entry);
}
/// Inserts an entry as last place in the list and animates it.
///
/// If entry is null, this is a no-op.
void _insertAndAnimate(
List<_ChildEntry> entries,
_ChildEntry? entry,
_ChildAnimationDirection direction,
) {
if (entry == null) return;
_moveToEnd(entries, entry);
switch (direction) {
case _ChildAnimationDirection.primaryForward:
unawaited(entry.primaryController.forward(from: 0));
entry.secondaryController.value = 0;
case _ChildAnimationDirection.primaryReverse:
unawaited(entry.primaryController.reverse(from: 1));
entry.secondaryController.value = 0;
case _ChildAnimationDirection.secondaryForward:
entry.primaryController.value = 1;
unawaited(entry.secondaryController.forward(from: 0));
case _ChildAnimationDirection.secondaryReverse:
entry.primaryController.value = 1;
unawaited(entry.secondaryController.reverse(from: 1));
}
}
/// Updates the list of child entries.
/// Ensures to order the list appropriately and animate entries in and out.
void _updateEntriesList() {
final List<_ChildEntry> entries = [];
final _ChildEntry? previousEntry = _currentEntry;
_ChildEntry? currentEntry;
Widget? currentChild;
if (widget.index != null && widget.children.isNotEmpty) {
currentChild = widget.children[widget.index!];
}
for (final child in widget.children) {
// We find the previous entry by looking for an identical child widget.
// If the children of this Stack share widget types, they must be given unique keys.
final int existingIndex = _entries.indexWhere(
(entry) => Widget.canUpdate(entry.child, child),
);
_ChildEntry? existingEntry;
if (existingIndex != -1) {
existingEntry = _entries[existingIndex];
}
_ChildEntry entry;
if (existingEntry != null) {
// If we find an existing entry, we update its child widget and reuse it.
// This ensures it continues to use the same global key and animation controllers.
existingEntry.child = child;
existingEntry.primaryController.duration = _effectiveDuration;
existingEntry.secondaryController.duration = _effectiveDuration;
entry = existingEntry;
} else {
entry = _newEntry(child);
}
if (currentChild == child) {
currentEntry = entry;
}
entries.add(entry);
}
final bool hasChanged = previousEntry != currentEntry;
final bool previousWasRemoved =
previousEntry != null && !entries.contains(previousEntry);
if (hasChanged) {
if (widget.reverse) {
// When reverse is true, the new child will transition in below the
// old child while its secondary animation and the primary
// animation of the old child are running in reverse. This is similar to
// the transition associated with popping a [PageRoute] to reveal a new
// [PageRoute] below it.
_insertAndAnimate(
entries,
currentEntry,
_ChildAnimationDirection.secondaryReverse,
);
_insertAndAnimate(
entries,
previousEntry,
_ChildAnimationDirection.primaryReverse,
);
if (previousWasRemoved) {
previousEntry.primaryController.addStatusListener((status) {
if (status == AnimationStatus.dismissed) {
setState(() {
_entries.remove(previousEntry);
previousEntry.dispose();
});
}
});
}
} else {
// When reverse is false, the new child will transition in on top of the
// old child while its primary animation and the secondary
// animation of the old child are running forward. This is similar to
// the transition associated with pushing a new [PageRoute] on top of
// another.
_insertAndAnimate(
entries,
previousEntry,
_ChildAnimationDirection.secondaryForward,
);
_insertAndAnimate(
entries,
currentEntry,
_ChildAnimationDirection.primaryForward,
);
if (previousWasRemoved) {
previousEntry.secondaryController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
setState(() {
_entries.remove(previousEntry);
previousEntry.dispose();
});
}
});
}
}
} else {
if (widget.reverse) {
_moveToEnd(entries, currentEntry);
_moveToEnd(entries, previousEntry);
} else {
_moveToEnd(entries, previousEntry);
_moveToEnd(entries, currentEntry);
}
}
setState(() {
_entries = entries;
_currentEntry = currentEntry;
});
}
_ChildEntry _newEntry(Widget child) => _ChildEntry(
key: GlobalKey(),
child: child,
primaryController: AnimationController(
duration: _effectiveDuration,
vsync: this,
),
secondaryController: AnimationController(
duration: _effectiveDuration,
vsync: this,
),
);
@override
void dispose() {
for (final entry in _entries) {
entry.dispose();
}
super.dispose();
}
Widget _buildChild(_ChildEntry entry) => AnimatedBuilder(
animation: Listenable.merge([
entry.primaryController,
entry.secondaryController,
]),
builder: (context, child) {
final bool isVisible =
entry.primaryController.isAnimating ||
entry.secondaryController.isAnimating ||
entry == _currentEntry;
return Visibility(
visible: isVisible,
maintainState: true,
child: widget.transitionBuilder(
KeyedSubtree(key: entry.key, child: child!),
entry.primaryController,
entry.secondaryController,
),
);
},
child: entry.child,
);
@override
Widget build(BuildContext context) {
return widget.layoutBuilder(_entries.map(_buildChild).toList());
}
}
@@ -0,0 +1,244 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/extensions/string.dart';
import 'package:weblibre/utils/text_field_line_count.dart';
class AutoSuggestTextField extends HookWidget {
final TextEditingController controller;
final String? suggestion;
final TextStyle? style;
final TextStyle? labelStyle;
final InputDecoration? decoration;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final TextCapitalization textCapitalization;
final bool autofocus;
final bool obscureText;
final int? maxLines;
final int? minLines;
final int? maxLength;
final ValueChanged<String>? onChanged;
final VoidCallback? onEditingComplete;
final FormFieldValidator<String>? validator;
final ValueChanged<String>? onSubmitted;
final List<TextInputFormatter>? inputFormatters;
final bool? enabled;
final FocusNode? focusNode;
final Color? cursorColor;
final Color? suggestionHighlightColor;
final bool? enableIMEPersonalizedLearning;
final TapRegionCallback? onTapOutside;
final VoidCallback? onTap;
final bool autocorrect;
final GlobalKey? textFieldKey;
const AutoSuggestTextField({
super.key,
required this.controller,
this.suggestion,
this.style,
this.labelStyle,
this.decoration,
this.keyboardType,
this.textInputAction,
this.textCapitalization = TextCapitalization.none,
this.autofocus = false,
this.obscureText = false,
this.maxLines = 1,
this.minLines,
this.maxLength,
this.onChanged,
this.onEditingComplete,
this.validator,
this.onSubmitted,
this.inputFormatters,
this.enabled,
this.focusNode,
this.cursorColor,
this.suggestionHighlightColor,
this.enableIMEPersonalizedLearning = true,
this.onTapOutside,
this.onTap,
this.autocorrect = false,
this.textFieldKey,
});
bool _suggestionHasMatch() =>
suggestion != null &&
controller.text.isNotEmpty &&
suggestion!.startsWithIgnoreCase(controller.text);
@override
Widget build(BuildContext context) {
final textFieldKey =
this.textFieldKey ?? useMemoized<GlobalKey>(() => GlobalKey());
final effectiveStyle = style ?? Theme.of(context).textTheme.bodyLarge!;
final showSuggestion = useListenableSelector(controller, () {
if (maxLines != 1) {
final lines = getTextFieldLineCount(
textFieldKey,
controller.text,
effectiveStyle,
);
final suggestionLines = suggestion.mapNotNull(
(suggestion) =>
getTextFieldLineCount(textFieldKey, suggestion, effectiveStyle),
);
return lines == 1 && suggestionLines == 1;
}
return true;
});
final baseDecoration = decoration ?? const InputDecoration();
return Stack(
children: [
if (showSuggestion && suggestion != null)
AbsorbPointer(
child: TextField(
minLines: minLines,
maxLines: maxLines,
maxLength: maxLength,
decoration: baseDecoration.copyWith(
floatingLabelBehavior: FloatingLabelBehavior.never,
border: InputBorder.none,
suffixIcon: baseDecoration.suffixIcon.mapNotNull(
(_) => const SizedBox.square(dimension: 48),
),
prefixIcon: baseDecoration.prefixIcon.mapNotNull(
(_) => const SizedBox.square(dimension: 48),
),
label: HookBuilder(
builder: (context) {
final text = useListenableSelector(
controller,
() => controller.text,
);
useEffect(() {
TextSelection? lastSelection;
void handleSelectionChange() {
if (lastSelection != controller.selection) {
lastSelection = controller.selection;
if (lastSelection!.start != lastSelection!.end) {
if (_suggestionHasMatch()) {
controller.value = controller.value.copyWith(
text: suggestion,
selection: lastSelection!.expandTo(
TextPosition(offset: suggestion!.length),
),
);
}
}
}
}
controller.addListener(handleSelectionChange);
return () =>
controller.removeListener(handleSelectionChange);
});
if (!_suggestionHasMatch()) {
return const SizedBox.shrink();
}
//TODO: maybe change to Text.rich
return RichText(
maxLines: maxLines,
text: TextSpan(
text: text,
style: effectiveStyle.copyWith(
color: Colors.transparent,
),
children: <TextSpan>[
TextSpan(
text: suggestion!.substring(text.length),
style: effectiveStyle.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
backgroundColor:
suggestionHighlightColor ??
Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.40),
),
),
],
),
);
},
),
alignLabelWithHint: true,
),
),
),
TextFormField(
key: textFieldKey,
controller: controller,
focusNode: focusNode,
decoration: baseDecoration.copyWith(
label: baseDecoration.label ?? const Text(''),
floatingLabelBehavior:
baseDecoration.floatingLabelBehavior ??
FloatingLabelBehavior.never,
),
style: effectiveStyle,
keyboardType: keyboardType,
textInputAction: textInputAction,
textCapitalization: textCapitalization,
autofocus: autofocus,
obscureText: obscureText,
maxLines: maxLines,
minLines: minLines,
maxLength: maxLength,
onChanged: onChanged,
onEditingComplete: onEditingComplete,
validator: validator,
autocorrect: autocorrect,
onFieldSubmitted: onSubmitted.mapNotNull(
(onSubmitted) => (value) {
if (_suggestionHasMatch()) {
onSubmitted(suggestion!);
} else {
onSubmitted(value);
}
},
),
inputFormatters: inputFormatters,
enabled: enabled,
cursorColor: cursorColor,
enableIMEPersonalizedLearning: enableIMEPersonalizedLearning ?? true,
onTapOutside: onTapOutside,
onTap: onTap,
),
],
);
}
}
@@ -0,0 +1,217 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
class BrowserPage extends ConsumerWidget {
final double bottomViewportInset;
final Widget child;
const BrowserPage({
super.key,
this.bottomViewportInset = 0,
required this.child,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final appColors = AppColors.of(context);
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color.alphaBlend(
appColors.auraPurple.withValues(alpha: 0.38),
colorScheme.surfaceContainerLowest,
),
Color.alphaBlend(
appColors.auraShadow.withValues(alpha: 0.72),
colorScheme.surface,
),
Color.alphaBlend(
appColors.auraGold.withValues(alpha: 0.34),
colorScheme.surfaceContainerHigh,
),
],
),
),
child: Stack(
fit: StackFit.expand,
children: [
Positioned(
top: -70,
left: -120,
child: _BackdropOrb(
width: 400,
height: 400,
color: appColors.auraPurple,
),
),
Positioned(
top: 220,
right: -150,
child: _BackdropOrb(
width: 340,
height: 340,
color: appColors.auraGold,
),
),
Positioned(
bottom: 18,
left: -8,
child: _BackdropOrb(
width: 320,
height: 320,
color: appColors.auraShadowHighlight,
),
),
Positioned.fill(
child: IgnorePointer(
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 72, sigmaY: 72),
child: ColoredBox(
color: appColors.auraTint.withValues(alpha: 0.12),
),
),
),
),
),
Positioned.fill(child: child),
],
),
);
}
}
class BrowserPageContent extends StatelessWidget {
final double bottomViewportInset;
final Widget child;
const BrowserPageContent({
super.key,
this.bottomViewportInset = 0,
required this.child,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(24, 32, 24, 32 + bottomViewportInset),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: math.max(
0,
constraints.maxHeight - 64 - bottomViewportInset,
),
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: child,
),
),
),
);
},
);
}
}
class BrandHeader extends StatelessWidget {
final ColorScheme colorScheme;
const BrandHeader({super.key, required this.colorScheme});
@override
Widget build(BuildContext context) {
return Container(
width: 112,
height: 112,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color.alphaBlend(
AppColors.brandPurple.withValues(alpha: 0.18),
colorScheme.surfaceContainerHighest,
),
Color.alphaBlend(
AppColors.brandYellow.withValues(alpha: 0.12),
colorScheme.surfaceContainer,
),
],
),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.45),
),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.08),
blurRadius: 32,
offset: const Offset(0, 18),
),
],
),
child: Center(
child: SvgPicture.asset('assets/icon/icon.svg', width: 72, height: 72),
),
);
}
}
class _BackdropOrb extends StatelessWidget {
final double width;
final double height;
final Color color;
const _BackdropOrb({
required this.width,
required this.height,
required this.color,
});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
width: width,
height: height,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
);
}
}
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class FailureWidget extends StatelessWidget {
const FailureWidget({
super.key,
this.title,
this.exception,
this.onRetry,
this.compact = false,
});
final String? title;
final dynamic exception;
final VoidCallback? onRetry;
final bool compact;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(title ?? 'Something went wrong'),
subtitle: exception != null
? switch (exception) {
final String string => Text(string),
_ => Text(exception.runtimeType.toString()),
}
: null,
trailing: compact && onRetry != null
? IconButton.outlined(
onPressed: onRetry,
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: const Icon(Icons.refresh_outlined),
)
: null,
textColor: Theme.of(context).colorScheme.error,
),
if (!compact && onRetry != null)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: onRetry,
style: OutlinedButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
label: const Text('Retry'),
icon: const Icon(Icons.refresh_outlined),
),
),
),
],
),
);
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
import 'package:weblibre/features/qr_scanner/presentation/dialogs/qr_scanner_dialog.dart';
class QrScannerButton extends HookConsumerWidget {
final void Function(Barcode? scanResult) onScanResult;
const QrScannerButton({super.key, required this.onScanResult});
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
onPressed: () async {
final result = await showDialog<Barcode>(
context: context,
builder: (context) {
return const QrScannerDialog();
},
);
onScanResult(result);
},
icon: const Icon(MdiIcons.barcodeScan),
);
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class RoundedBackground extends StatelessWidget {
final Widget child;
final Color? backgroundColor;
const RoundedBackground({
super.key,
required this.child,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25),
color: backgroundColor ?? Theme.of(context).colorScheme.primary,
),
child: child,
);
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/widgets.dart';
import 'package:weblibre/domain/entities/equatable_image.dart';
/// A safe wrapper around [RawImage] that guards against disposed images.
///
/// Checks [EquatableImage.isDisposed] before rendering. When the image
/// is null or disposed, renders [fallback] (defaults to an empty SizedBox
/// matching the requested dimensions).
class SafeRawImage extends StatelessWidget {
final EquatableImage? image;
final double? width;
final double? height;
final BoxFit? fit;
final Widget? fallback;
const SafeRawImage({
super.key,
required this.image,
this.width,
this.height,
this.fit,
this.fallback,
});
@override
Widget build(BuildContext context) {
final uiImage = image?.value;
if (uiImage == null) {
return fallback ?? SizedBox(width: width, height: height);
}
return RawImage(image: uiImage, width: width, height: height, fit: fit);
}
}
@@ -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/>.
*/
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:nullability/nullability.dart';
class _BadgeWrapper extends StatelessWidget {
final Widget child;
final int? count;
const _BadgeWrapper({required this.child, this.count});
@override
Widget build(BuildContext context) {
return count != null
? Badge.count(
count: count!,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
textColor: Theme.of(context).colorScheme.onPrimaryContainer,
child: child,
)
: child;
}
}
class _GestureWrapper extends StatelessWidget {
final Widget child;
final GestureLongPressCallback? onLongPress;
const _GestureWrapper({required this.child, this.onLongPress});
@override
Widget build(BuildContext context) {
return onLongPress != null
? InkWell(onLongPress: onLongPress, child: child)
: child;
}
}
class SelectableChips<T extends S, S, K> extends StatelessWidget {
final Iterable<T> availableItems;
final List<Widget> prefixListItems;
final S? selectedItem;
final int maxCount;
final bool enableDelete;
final bool sortSelectedFirst;
final ScrollController? scrollController;
final K Function(S item) itemId;
final Widget Function(T item) itemLabel;
final Widget? Function(T item)? itemAvatar;
final String? Function(T item)? itemTooltip;
final int? Function(T item)? itemBadgeCount;
final Color? Function(T item)? itemBackgroundColor;
final Color? selectedBorderColor;
final EdgeInsetsGeometry? Function(T item)? labelPadding;
final Widget Function(Widget child, S item)? itemWrap;
final void Function(T item)? onSelected;
final void Function(T item)? onDeleted;
final void Function(T item)? onLongPress;
const SelectableChips({
required this.itemId,
required this.itemLabel,
this.itemAvatar,
this.itemBadgeCount,
this.itemWrap,
this.itemTooltip,
this.itemBackgroundColor,
this.selectedBorderColor,
this.prefixListItems = const [],
required this.availableItems,
this.selectedItem,
this.maxCount = 25,
this.enableDelete = true,
this.onSelected,
this.onDeleted,
this.onLongPress,
this.sortSelectedFirst = true,
this.scrollController,
this.labelPadding,
super.key,
});
@override
Widget build(BuildContext context) {
var items = availableItems.take(maxCount).toList();
if (sortSelectedFirst) {
if (selectedItem case final T selectedItem) {
final selectedIndex = items.indexWhere(
(item) => itemId(item) == itemId(selectedItem),
);
if (selectedIndex < 0) {
items = [selectedItem, ...items];
} else {
items = [items.removeAt(selectedIndex), ...items];
}
}
}
return FadingScroll(
controller: scrollController,
fadingSize: 15,
builder: (context, controller) {
return ListView.builder(
controller: controller,
//Improve list performance by not rendering outside screen at all
cacheExtent: 0,
scrollDirection: Axis.horizontal,
itemCount: prefixListItems.length + items.length,
itemBuilder: (context, index) {
if (index < prefixListItems.length) {
return Padding(
padding: const EdgeInsets.only(right: 8.0, top: 4.0),
child: prefixListItems[index],
);
}
final item = items[index - prefixListItems.length];
final isSelected =
selectedItem != null &&
itemId(item) == itemId(selectedItem as S);
final child = Padding(
padding: const EdgeInsets.only(right: 8.0, top: 4.0),
child: _BadgeWrapper(
count: itemBadgeCount?.call(item),
child: _GestureWrapper(
onLongPress: onLongPress.mapNotNull(
(callback) =>
() => callback(item),
),
child: FilterChip(
selected: selectedBorderColor == null && isSelected,
showCheckmark: false,
labelPadding: labelPadding?.call(item),
onSelected: (value) {
if (value) {
onSelected?.call(item);
} else {
onDeleted?.call(item);
}
},
onDeleted: enableDelete
? () {
onDeleted?.call(item);
}
: null,
label: itemLabel.call(item),
avatar: itemAvatar?.call(item),
tooltip: itemTooltip?.call(item),
backgroundColor: itemBackgroundColor?.call(item),
side: isSelected && selectedBorderColor != null
? BorderSide(color: selectedBorderColor!, width: 2.0)
: null,
),
),
),
);
return (itemWrap != null) ? itemWrap!(child, item) : child;
},
);
},
);
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class ShareTile extends StatelessWidget {
final void Function()? onTap;
final void Function()? onTapQr;
const ShareTile({super.key, this.onTap, this.onTapQr});
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(Icons.share),
title: const Text('Share link'),
onTap: onTap,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
const VerticalDivider(indent: 4, endIndent: 4),
IconButton(icon: const Icon(Icons.qr_code), onPressed: onTapQr),
],
),
);
}
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
class SlidingPillToggle extends StatelessWidget {
final int selectedIndex;
final List<String> labels;
final ValueChanged<int> onChanged;
const SlidingPillToggle({
super.key,
required this.selectedIndex,
required this.labels,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final disableAnimations = MediaQuery.disableAnimationsOf(context);
return Container(
height: 36,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Stack(
children: [
AnimatedAlign(
alignment: Alignment(
-1.0 + (2.0 * selectedIndex / (labels.length - 1)),
0.0,
),
duration: disableAnimations
? Duration.zero
: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
child: FractionallySizedBox(
widthFactor: 1.0 / labels.length,
child: Container(
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(18),
),
),
),
),
Row(
children: [
for (var i = 0; i < labels.length; i++)
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(i),
child: Center(
child: Text(
labels[i],
style: theme.textTheme.labelMedium?.copyWith(
color: i == selectedIndex
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
fontWeight: i == selectedIndex
? FontWeight.w600
: FontWeight.normal,
),
),
),
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:speech_to_text_dialog/speech_to_text_dialog.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class SpeechToTextButton extends HookWidget {
final Function(String text) onTextReceived;
const SpeechToTextButton({required this.onTextReceived, super.key});
@override
Widget build(BuildContext context) {
final speechDialog = useMemoized(() => SpeechToTextDialog());
final subscription = useRef<StreamSubscription<String>?>(null);
useEffect(() {
return () {
subscription.value?.cancel().ignore();
speechDialog.dispose();
};
}, [speechDialog]);
Future<void> showSpeechDialog() async {
// Cancel any existing subscription
await subscription.value?.cancel();
// Listen for the next text result
subscription.value = speechDialog.textStream.take(1).listen((text) {
if (text.isNotEmpty) {
onTextReceived(text);
}
});
// Show the dialog
final isServiceAvailable = await speechDialog.showDialog(
locale: PlatformDispatcher.instance.locale.toLanguageTag(),
);
if (!isServiceAvailable) {
if (context.mounted) {
ui_helper.showErrorMessage(context, 'Service is not available');
}
await subscription.value?.cancel();
}
}
return IconButton(onPressed: showSpeechDialog, icon: const Icon(Icons.mic));
}
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:collection/collection.dart';
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
class UriBreadcrumb extends StatelessWidget {
final Uri uri;
final Widget? icon;
final TextStyle? style;
final void Function()? onTooltipTriggered;
const UriBreadcrumb({
super.key,
required this.uri,
this.icon,
this.style,
this.onTooltipTriggered,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: uri.toString(),
onTriggered: onTooltipTriggered,
child: DefaultTextStyle(
style: style ?? DefaultTextStyle.of(context).style,
child: FadingScroll(
fadingSize: 15,
builder: (context, controller) {
return SingleChildScrollView(
controller: controller,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
?icon,
Text(
uri.authority,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (uri.pathSegments.any((s) => s.isNotEmpty))
Text(
' ${uri.pathSegments.whereNot((s) => s.isEmpty).join(' ')}',
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
),
],
),
);
},
),
),
);
}
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/domain/services/generic_website.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
class UrlIcon extends HookConsumerWidget {
final double iconSize;
final List<Uri> urlList;
const UrlIcon(this.urlList, {required this.iconSize, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final icon = useCachedFuture(
() =>
// ignore: discarded_futures
ref.read(genericWebsiteServiceProvider.notifier).getUrlIcon(urlList),
[EquatableValue(urlList)],
);
return Skeletonizer(
enabled: icon.connectionState != ConnectionState.done,
child: SizedBox.square(
dimension: iconSize,
child: (icon.data != null)
? RepaintBoundary(
child: SafeRawImage(
image: icon.data?.image,
height: iconSize,
width: iconSize,
fit: BoxFit.fill,
fallback: Icon(MdiIcons.web, size: iconSize),
),
)
: Icon(MdiIcons.web, size: iconSize),
),
);
}
}
@@ -0,0 +1,111 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class UrlListTile extends StatelessWidget {
final String title;
final Uri uri;
final Widget? leading;
final Widget? trailing;
final Color? borderColor;
final VoidCallback? onTap;
const UrlListTile({
super.key,
required this.title,
required this.uri,
this.leading,
this.trailing,
this.borderColor,
this.onTap,
});
static const iconSize = 32.0;
static const _borderRadius = BorderRadius.all(Radius.circular(12.0));
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 4.0),
decoration: BoxDecoration(
borderRadius: _borderRadius,
border: borderColor != null
? Border(right: BorderSide(color: borderColor!, width: 4.0))
: null,
),
child: Material(
color: Colors.transparent,
borderRadius: _borderRadius,
clipBehavior: Clip.antiAlias,
child: InkWell(
borderRadius: _borderRadius,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(
left: 12.0,
top: 10.0,
bottom: 10.0,
right: 12.0,
),
child: Row(
children: [
leading ??
RepaintBoundary(child: UrlIcon([uri], iconSize: iconSize)),
const SizedBox(width: 14.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3.0),
UriBreadcrumb(
uri: uri,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (trailing != null) ...[
const SizedBox(width: 8.0),
trailing!,
],
],
),
),
),
),
);
}
}
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/presentation/controllers/website_title.dart';
import 'package:weblibre/presentation/widgets/rounded_text.dart';
class WebsiteFeedMenuButton extends HookConsumerWidget {
final String tabId;
const WebsiteFeedMenuButton(this.tabId, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final feedsAsync = ref.watch(websiteFeedProviderProvider(tabId));
return Skeletonizer(
enabled: feedsAsync.isLoading && feedsAsync.value?.value == null,
child: feedsAsync.when(
skipLoadingOnReload: true,
data: (feeds) {
if (feeds.value.isEmpty) {
return const SizedBox.shrink();
}
return MenuItemButton(
leadingIcon: const Icon(Icons.rss_feed),
closeOnActivate: false,
trailingIcon: RoundedBackground(
child: Text(
feeds.value!.length.toString(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
onPressed: () async {
await SelectFeedDialogRoute(
feedsJson: jsonEncode(
feeds.value!.map((feed) => feed.toString()).toList(),
),
).push(context);
},
child: const Text('Available Web Feeds'),
);
},
error: (error, stackTrace) {
return const SizedBox.shrink();
//Will be dispalyed on title already
// return FailureWidget(
// title: error.toString(),
// onRetry: () => ref.refresh(pageInfoProvider(url)),
// );
},
loading: () => const MenuItemButton(
leadingIcon: Icon(Icons.rss_feed),
child: Text('Available Web Feeds'),
),
),
);
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/presentation/controllers/website_title.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
class WebsiteTitleTile extends HookConsumerWidget {
final TabState initialTabState;
const WebsiteTitleTile(this.initialTabState, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pageInfoAsync = ref.watch(completePageInfoProvider(initialTabState));
return Skeletonizer(
enabled: pageInfoAsync.isLoading,
child: pageInfoAsync.when(
skipLoadingOnReload: true,
data: (info) {
return ListTile(
leading: RepaintBoundary(
child:
info.favicon.mapNotNull(
(favicon) => SafeRawImage(
image: favicon.image,
height: 24,
width: 24,
fallback: const Icon(MdiIcons.web, size: 24),
),
) ??
const Icon(MdiIcons.web, size: 24),
),
contentPadding: EdgeInsets.zero,
title: Text(
info.title.whenNotEmpty ?? info.url.authority,
maxLines: 6,
overflow: TextOverflow.ellipsis,
),
subtitle: UriBreadcrumb(uri: initialTabState.url),
);
},
error: (error, stackTrace) {
return FailureWidget(
title: error.toString(),
onRetry: () => ref.refresh(
pageInfoProvider(initialTabState.url, isImageRequest: false),
),
);
},
loading: () => ListTile(
leading: SafeRawImage(
image: initialTabState.favicon?.image,
height: 24,
width: 24,
),
contentPadding: EdgeInsets.zero,
title: Text(initialTabState.titleOrAuthority),
subtitle: UriBreadcrumb(uri: initialTabState.url),
),
),
);
}
}