first implementation finished

This commit is contained in:
Fabian Freund
2024-04-22 11:21:01 +02:00
parent 1a13ca0795
commit 0e1972242a
102 changed files with 7192 additions and 55 deletions
@@ -0,0 +1,11 @@
import 'package:kagi_bang_bang/domain/entities/web_page_info.dart';
import 'package:kagi_bang_bang/domain/services/generic_website.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'website_title.g.dart';
@Riverpod(keepAlive: true)
Future<WebPageInfo> pageInfo(PageInfoRef ref, Uri url) async {
final websiteService = ref.watch(genericWebsiteServiceProvider.notifier);
return websiteService.getInfo(url).then((value) => value.value);
}
@@ -0,0 +1,157 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'website_title.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$pageInfoHash() => r'47ebd4256eb405281b23791a114525447145baec';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [pageInfo].
@ProviderFor(pageInfo)
const pageInfoProvider = PageInfoFamily();
/// See also [pageInfo].
class PageInfoFamily extends Family<AsyncValue<WebPageInfo>> {
/// See also [pageInfo].
const PageInfoFamily();
/// See also [pageInfo].
PageInfoProvider call(
Uri url,
) {
return PageInfoProvider(
url,
);
}
@override
PageInfoProvider getProviderOverride(
covariant PageInfoProvider provider,
) {
return call(
provider.url,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'pageInfoProvider';
}
/// See also [pageInfo].
class PageInfoProvider extends FutureProvider<WebPageInfo> {
/// See also [pageInfo].
PageInfoProvider(
Uri url,
) : this._internal(
(ref) => pageInfo(
ref as PageInfoRef,
url,
),
from: pageInfoProvider,
name: r'pageInfoProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$pageInfoHash,
dependencies: PageInfoFamily._dependencies,
allTransitiveDependencies: PageInfoFamily._allTransitiveDependencies,
url: url,
);
PageInfoProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.url,
}) : super.internal();
final Uri url;
@override
Override overrideWith(
FutureOr<WebPageInfo> Function(PageInfoRef provider) create,
) {
return ProviderOverride(
origin: this,
override: PageInfoProvider._internal(
(ref) => create(ref as PageInfoRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
url: url,
),
);
}
@override
FutureProviderElement<WebPageInfo> createElement() {
return _PageInfoProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is PageInfoProvider && other.url == url;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, url.hashCode);
return _SystemHash.finish(hash);
}
}
mixin PageInfoRef on FutureProviderRef<WebPageInfo> {
/// The parameter `url` of this provider.
Uri get url;
}
class _PageInfoProviderElement extends FutureProviderElement<WebPageInfo>
with PageInfoRef {
_PageInfoProviderElement(super.provider);
@override
Uri get url => (origin as PageInfoProvider).url;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -0,0 +1,12 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
void useListenableCallback(Listenable? listenable, void Function() callback) {
useEffect(
() {
listenable?.addListener(callback);
return () => listenable?.removeListener(callback);
},
[listenable],
);
}
@@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
void useOnDispose(VoidCallback onDispose) {
useEffect(
() {
return onDispose;
},
const [],
);
}
@@ -0,0 +1,13 @@
import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
void useOnInitialization(FutureOr<void> Function() callback) {
useEffect(
() {
Future.microtask(callback);
return null;
},
[],
);
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
OverlayPortalController useOverlayPortalController() {
return use(const _OverlayPortalControllerHook());
}
class _OverlayPortalControllerHook extends Hook<OverlayPortalController> {
const _OverlayPortalControllerHook();
@override
HookState<OverlayPortalController, Hook<OverlayPortalController>>
createState() {
return _OverlayPortalControllerHookState();
}
}
class _OverlayPortalControllerHookState
extends HookState<OverlayPortalController, _OverlayPortalControllerHook> {
late final controller = OverlayPortalController();
@override
OverlayPortalController build(BuildContext context) => controller;
@override
String get debugLabel => 'useOverlayPortalController';
}
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
void useSyncPageWithTab(
TabController tabController,
PageController pageController,
) {
useEffect(
() {
Future<void> syncPage() async {
await pageController.animateToPage(
tabController.index,
curve: Curves.linear,
duration: const Duration(milliseconds: 300),
);
}
void syncTab() {
if (!tabController.indexIsChanging) {
tabController.animateTo(pageController.page!.round());
}
}
tabController.addListener(syncPage);
pageController.addListener(syncTab);
return () {
tabController.removeListener(syncPage);
pageController.removeListener(syncTab);
};
},
[tabController, pageController],
);
}
@@ -0,0 +1,329 @@
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;
@override
void initState() {
super.initState();
_updateEntriesList();
}
@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:
entry.primaryController.forward(from: 0);
entry.secondaryController.value = 0;
case _ChildAnimationDirection.primaryReverse:
entry.primaryController.reverse(from: 1);
entry.secondaryController.value = 0;
case _ChildAnimationDirection.secondaryForward:
entry.primaryController.value = 1;
entry.secondaryController.forward(from: 0);
case _ChildAnimationDirection.secondaryReverse:
entry.primaryController.value = 1;
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 = widget.duration;
existingEntry.secondaryController.duration = widget.duration;
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: widget.duration,
vsync: this,
),
secondaryController: AnimationController(
duration: widget.duration,
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,436 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class _AutocompleteCallbackAction<T extends Intent> extends CallbackAction<T> {
_AutocompleteCallbackAction({
required super.onInvoke,
required this.isEnabledCallback,
});
// The enabled state determines whether the action will consume the
// key shortcut or let it continue on to the underlying text field.
// They should only be enabled when the options are showing so shortcuts
// can be used to navigate them.
final bool Function() isEnabledCallback;
@override
bool isEnabled(covariant T intent) => isEnabledCallback();
@override
bool consumesKey(covariant T intent) => isEnabled(intent);
}
class ExternalResultsAutocomplete<T extends Object> extends StatefulWidget {
/// Create an instance of RawAutocomplete.
///
/// [displayStringForOption], [onTextChanged] and [optionsViewBuilder] must
/// not be null.
const ExternalResultsAutocomplete({
super.key,
required this.optionsViewBuilder,
required this.onTextChanged,
required this.optionsStream,
this.optionsViewOpenDirection = OptionsViewOpenDirection.down,
this.displayStringForOption = defaultStringForOption,
this.fieldViewBuilder,
this.focusNode,
this.onSelected,
this.textEditingController,
this.initialValue,
}) : assert(
fieldViewBuilder != null ||
(key != null &&
focusNode != null &&
textEditingController != null),
'Pass in a fieldViewBuilder, or otherwise create a separate field and pass in the FocusNode, TextEditingController, and a key. Use the key with RawAutocomplete.onFieldSubmitted.',
),
assert((focusNode == null) == (textEditingController == null)),
assert(
!(textEditingController != null && initialValue != null),
'textEditingController and initialValue cannot be simultaneously defined.',
);
/// {@template flutter.widgets.RawAutocomplete.fieldViewBuilder}
/// Builds the field whose input is used to get the options.
///
/// Pass the provided [TextEditingController] to the field built here so that
/// RawAutocomplete can listen for changes.
/// {@endtemplate}
///
/// If this parameter is null, then a [SizedBox.shrink] is built instead.
/// For how that pattern can be useful, see [textEditingController].
final AutocompleteFieldViewBuilder? fieldViewBuilder;
/// The [FocusNode] that is used for the text field.
///
/// {@template flutter.widgets.RawAutocomplete.split}
/// The main purpose of this parameter is to allow the use of a separate text
/// field located in another part of the widget tree instead of the text
/// field built by [fieldViewBuilder]. For example, it may be desirable to
/// place the text field in the AppBar and the options below in the main body.
///
/// When following this pattern, [fieldViewBuilder] can be omitted,
/// so that a text field is not drawn where it would normally be.
/// A separate text field can be created elsewhere, and a
/// FocusNode and TextEditingController can be passed both to that text field
/// and to RawAutocomplete.
///
/// {@tool dartpad}
/// This examples shows how to create an autocomplete widget with the text
/// field in the AppBar and the results in the main body of the app.
///
/// ** See code in examples/api/lib/widgets/autocomplete/raw_autocomplete.focus_node.0.dart **
/// {@end-tool}
/// {@endtemplate}
///
/// If this parameter is not null, then [textEditingController] must also be
/// not null.
final FocusNode? focusNode;
/// {@template flutter.widgets.RawAutocomplete.optionsViewBuilder}
/// Builds the selectable options widgets from a list of options objects.
///
/// The options are displayed floating below or above the field using a
/// [CompositedTransformFollower] inside of an [Overlay], not at the same
/// place in the widget tree as [ExternalResultsAutocomplete]. To control whether it opens
/// upward or downward, use [optionsViewOpenDirection].
///
/// In order to track which item is highlighted by keyboard navigation, the
/// resulting options will be wrapped in an inherited
/// [AutocompleteHighlightedOption] widget.
/// Inside this callback, the index of the highlighted option can be obtained
/// from [AutocompleteHighlightedOption.of] to display the highlighted option
/// with a visual highlight to indicate it will be the option selected from
/// the keyboard.
///
/// {@endtemplate}
final AutocompleteOptionsViewBuilder<T> optionsViewBuilder;
/// {@template flutter.widgets.RawAutocomplete.optionsViewOpenDirection}
/// The direction in which to open the options-view overlay.
///
/// Defaults to [OptionsViewOpenDirection.down].
/// {@endtemplate}
final OptionsViewOpenDirection optionsViewOpenDirection;
/// {@template flutter.widgets.RawAutocomplete.displayStringForOption}
/// Returns the string to display in the field when the option is selected.
///
/// This is useful when using a custom T type and the string to display is
/// different than the string to search by.
///
/// If not provided, will use `option.toString()`.
/// {@endtemplate}
final AutocompleteOptionToString<T> displayStringForOption;
/// {@template flutter.widgets.RawAutocomplete.onSelected}
/// Called when an option is selected by the user.
/// {@endtemplate}
final AutocompleteOnSelected<T>? onSelected;
final FutureOr<void> Function(TextEditingValue textEditingValue)
onTextChanged;
/// The [TextEditingController] that is used for the text field.
///
/// {@macro flutter.widgets.RawAutocomplete.split}
///
/// If this parameter is not null, then [focusNode] must also be not null.
final TextEditingController? textEditingController;
/// {@template flutter.widgets.RawAutocomplete.initialValue}
/// The initial value to use for the text field.
/// {@endtemplate}
///
/// Setting the initial value does not notify [textEditingController]'s
/// listeners, and thus will not cause the options UI to appear.
///
/// This parameter is ignored if [textEditingController] is defined.
final TextEditingValue? initialValue;
final Stream<Iterable<T>> optionsStream;
/// Calls [AutocompleteFieldViewBuilder]'s onFieldSubmitted callback for the
/// RawAutocomplete widget indicated by the given [GlobalKey].
///
/// This is not typically used unless a custom field is implemented instead of
/// using [fieldViewBuilder]. In the typical case, the onFieldSubmitted
/// callback is passed via the [AutocompleteFieldViewBuilder] signature. When
/// not using fieldViewBuilder, the same callback can be called by using this
/// static method.
///
/// See also:
///
/// * [focusNode] and [textEditingController], which contain a code example
/// showing how to create a separate field outside of fieldViewBuilder.
static void onFieldSubmitted<T extends Object>(GlobalKey key) {
final _RawAutocompleteState<T> rawAutocomplete =
key.currentState! as _RawAutocompleteState<T>;
rawAutocomplete._onFieldSubmitted();
}
/// The default way to convert an option to a string in
/// [displayStringForOption].
///
/// Uses the `toString` method of the given `option`.
static String defaultStringForOption(Object? option) {
return option.toString();
}
@override
State<ExternalResultsAutocomplete<T>> createState() =>
_RawAutocompleteState<T>();
}
class _RawAutocompleteState<T extends Object>
extends State<ExternalResultsAutocomplete<T>> {
final GlobalKey _fieldKey = GlobalKey();
final LayerLink _optionsLayerLink = LayerLink();
final OverlayPortalController _optionsViewController =
OverlayPortalController(debugLabel: '_RawAutocompleteState');
TextEditingController? _internalTextEditingController;
TextEditingController get _textEditingController {
return widget.textEditingController ??
(_internalTextEditingController ??= TextEditingController()
..addListener(_onChangedField));
}
FocusNode? _internalFocusNode;
FocusNode get _focusNode {
return widget.focusNode ??
(_internalFocusNode ??= FocusNode()
..addListener(_updateOptionsViewVisibility));
}
late final Map<Type, CallbackAction<Intent>> _actionMap =
<Type, CallbackAction<Intent>>{
AutocompletePreviousOptionIntent:
_AutocompleteCallbackAction<AutocompletePreviousOptionIntent>(
onInvoke: _highlightPreviousOption,
isEnabledCallback: () => _canShowOptionsView,
),
AutocompleteNextOptionIntent:
_AutocompleteCallbackAction<AutocompleteNextOptionIntent>(
onInvoke: _highlightNextOption,
isEnabledCallback: () => _canShowOptionsView,
),
DismissIntent: CallbackAction<DismissIntent>(onInvoke: _hideOptions),
};
late StreamSubscription<Iterable<T>> _optionsSubscription;
Iterable<T> _options = Iterable<T>.empty();
T? _selection;
// Set the initial value to null so when this widget gets focused for the first
// time it will try to run the options view builder.
String? _lastFieldText;
final ValueNotifier<int> _highlightedOptionIndex = ValueNotifier<int>(0);
static const Map<ShortcutActivator, Intent> _shortcuts =
<ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.arrowUp):
AutocompletePreviousOptionIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown):
AutocompleteNextOptionIntent(),
};
bool get _canShowOptionsView =>
_focusNode.hasFocus && _selection == null && _options.isNotEmpty;
void _updateOptionsViewVisibility() {
if (_canShowOptionsView) {
_optionsViewController.show();
} else {
_optionsViewController.hide();
}
}
void _onUpateOptions(Iterable<T> options) {
final TextEditingValue value = _textEditingController.value;
_options = options;
_updateHighlight(_highlightedOptionIndex.value);
final T? selection = _selection;
if (selection != null &&
value.text != widget.displayStringForOption(selection)) {
_selection = null;
}
// Make sure the options are no longer hidden if the content of the field
// changes (ignore selection changes).
if (value.text != _lastFieldText) {
_lastFieldText = value.text;
_updateOptionsViewVisibility();
}
}
// Called when _textEditingController changes.
Future<void> _onChangedField() async {
final TextEditingValue value = _textEditingController.value;
await widget.onTextChanged(value);
}
// Called from fieldViewBuilder when the user submits the field.
void _onFieldSubmitted() {
if (_optionsViewController.isShowing) {
_select(_options.elementAt(_highlightedOptionIndex.value));
}
}
// Select the given option and update the widget.
void _select(T nextSelection) {
if (nextSelection == _selection) {
return;
}
_selection = nextSelection;
final String selectionString = widget.displayStringForOption(nextSelection);
_textEditingController.value = TextEditingValue(
selection: TextSelection.collapsed(offset: selectionString.length),
text: selectionString,
);
widget.onSelected?.call(nextSelection);
_updateOptionsViewVisibility();
}
void _updateHighlight(int newIndex) {
_highlightedOptionIndex.value =
_options.isEmpty ? 0 : newIndex % _options.length;
}
void _highlightPreviousOption(AutocompletePreviousOptionIntent intent) {
assert(_canShowOptionsView);
_updateOptionsViewVisibility();
assert(_optionsViewController.isShowing);
_updateHighlight(_highlightedOptionIndex.value - 1);
}
void _highlightNextOption(AutocompleteNextOptionIntent intent) {
assert(_canShowOptionsView);
_updateOptionsViewVisibility();
assert(_optionsViewController.isShowing);
_updateHighlight(_highlightedOptionIndex.value + 1);
}
Object? _hideOptions(DismissIntent intent) {
if (_optionsViewController.isShowing) {
_optionsViewController.hide();
return null;
} else {
return Actions.invoke(context, intent);
}
}
Widget _buildOptionsView(BuildContext context) {
final TextDirection textDirection = Directionality.of(context);
final Alignment followerAlignment =
switch (widget.optionsViewOpenDirection) {
OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart,
OptionsViewOpenDirection.down => AlignmentDirectional.topStart,
}
.resolve(textDirection);
final Alignment targetAnchor = switch (widget.optionsViewOpenDirection) {
OptionsViewOpenDirection.up => AlignmentDirectional.topStart,
OptionsViewOpenDirection.down => AlignmentDirectional.bottomStart,
}
.resolve(textDirection);
return CompositedTransformFollower(
link: _optionsLayerLink,
showWhenUnlinked: false,
targetAnchor: targetAnchor,
followerAnchor: followerAlignment,
child: TextFieldTapRegion(
child: AutocompleteHighlightedOption(
highlightIndexNotifier: _highlightedOptionIndex,
child: Builder(
builder: (BuildContext context) =>
widget.optionsViewBuilder(context, _select, _options),
),
),
),
);
}
@override
void initState() {
super.initState();
final TextEditingController initialController =
widget.textEditingController ??
(_internalTextEditingController =
TextEditingController.fromValue(widget.initialValue));
initialController.addListener(_onChangedField);
widget.focusNode?.addListener(_updateOptionsViewVisibility);
_optionsSubscription = widget.optionsStream.listen(_onUpateOptions);
}
@override
void didUpdateWidget(ExternalResultsAutocomplete<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(
oldWidget.textEditingController,
widget.textEditingController,
)) {
oldWidget.textEditingController?.removeListener(_onChangedField);
if (oldWidget.textEditingController == null) {
_internalTextEditingController?.dispose();
_internalTextEditingController = null;
}
widget.textEditingController?.addListener(_onChangedField);
}
if (!identical(oldWidget.focusNode, widget.focusNode)) {
oldWidget.focusNode?.removeListener(_updateOptionsViewVisibility);
if (oldWidget.focusNode == null) {
_internalFocusNode?.dispose();
_internalFocusNode = null;
}
widget.focusNode?.addListener(_updateOptionsViewVisibility);
}
if (!identical(oldWidget.optionsStream, widget.optionsStream)) {
unawaited(_optionsSubscription.cancel());
_optionsSubscription = widget.optionsStream.listen(_onUpateOptions);
}
}
@override
void dispose() {
widget.textEditingController?.removeListener(_onChangedField);
_internalTextEditingController?.dispose();
widget.focusNode?.removeListener(_updateOptionsViewVisibility);
_internalFocusNode?.dispose();
_highlightedOptionIndex.dispose();
unawaited(_optionsSubscription.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) {
final Widget fieldView = widget.fieldViewBuilder?.call(
context,
_textEditingController,
_focusNode,
_onFieldSubmitted,
) ??
const SizedBox.shrink();
return OverlayPortal.targetsRootOverlay(
controller: _optionsViewController,
overlayChildBuilder: _buildOptionsView,
child: TextFieldTapRegion(
child: Container(
key: _fieldKey,
child: Shortcuts(
shortcuts: _shortcuts,
child: Actions(
actions: _actionMap,
child: CompositedTransformTarget(
link: _optionsLayerLink,
child: fieldView,
),
),
),
),
),
);
}
}
@@ -0,0 +1,59 @@
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 Object? 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
? 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,45 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:kagi_bang_bang/features/web_view/presentation/widgets/favicon.dart';
import 'package:kagi_bang_bang/presentation/controllers/website_title.dart';
import 'package:kagi_bang_bang/presentation/widgets/failure_widget.dart';
import 'package:skeletonizer/skeletonizer.dart';
class WebsiteTitleTile extends HookConsumerWidget {
final Uri url;
const WebsiteTitleTile(this.url, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final websiteTileAsync = ref.watch(pageInfoProvider(url));
return Skeletonizer(
enabled: websiteTileAsync.isLoading,
child: websiteTileAsync.when(
data: (info) {
return ListTile(
leading: FaviconImage(
webPageInfo: info,
size: 24,
),
contentPadding: EdgeInsets.zero,
title: Text(info.title ?? 'Unknown Title'),
subtitle: Text(url.authority),
);
},
error: (error, stackTrace) {
return FailureWidget(
title: error.toString(),
onRetry: () => ref.refresh(pageInfoProvider(url)),
);
},
loading: () => const ListTile(
contentPadding: EdgeInsets.zero,
title: Bone.text(),
subtitle: Bone.text(),
),
),
);
}
}