prepare for multiple apps
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user