Browse Source

Merge pull request #89 from jld3103/cleanup/notes-category-autocomplete

neon: Cleanup notes category autocomplete
pull/90/head
jld3103 2 years ago committed by GitHub
parent
commit
a2d70a3ae8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/neon/lib/src/apps/notes/app.dart
  2. 45
      packages/neon/lib/src/apps/notes/widgets/category_select.dart
  3. 493
      packages/neon/lib/src/widgets/custom_auto_complete.dart

1
packages/neon/lib/src/apps/notes/app.dart

@ -10,7 +10,6 @@ import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/apps/notes/blocs/notes.dart';
import 'package:neon/src/blocs/apps.dart';
import 'package:neon/src/neon.dart';
import 'package:neon/src/widgets/custom_auto_complete.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:provider/provider.dart';
import 'package:rxdart/rxdart.dart';

45
packages/neon/lib/src/apps/notes/widgets/category_select.dart

@ -21,7 +21,7 @@ class NotesCategorySelect extends StatelessWidget {
late final _categories = categories..sort((final a, final b) => a.compareTo(b));
@override
Widget build(final BuildContext context) => CustomAutocomplete<String>(
Widget build(final BuildContext context) => Autocomplete<String>(
initialValue: initialValue != null
? TextEditingValue(
text: initialValue!,
@ -59,19 +59,38 @@ class NotesCategorySelect extends StatelessWidget {
},
onChanged: onChanged,
),
displayWidgetForOption: (final category) => Row(
children: [
Icon(
MdiIcons.tag,
color: category != '' ? NotesCategoryColor.compute(category) : null,
optionsViewBuilder: (final context, final onSelected, final options) => Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (final context, final index) {
final option = options.elementAt(index);
return InkWell(
onTap: () {
onSelected(option);
},
child: Builder(
builder: (final context) => ListTile(
leading: Icon(
MdiIcons.tag,
color: option != '' ? NotesCategoryColor.compute(option) : null,
),
title: Text(
option != '' ? option : AppLocalizations.of(context).notesUncategorized,
),
),
),
);
},
),
),
const SizedBox(
width: 10,
),
Text(
category != '' ? category : AppLocalizations.of(context).notesUncategorized,
),
],
),
),
onSelected: (final value) {
if (categories.contains(value)) {

493
packages/neon/lib/src/widgets/custom_auto_complete.dart

@ -1,493 +0,0 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
class CustomAutocomplete<T extends Object> extends StatelessWidget {
const CustomAutocomplete({
required this.optionsBuilder,
this.displayWidgetForOption = CustomRawAutocomplete.defaultWidgetForOption,
this.fieldViewBuilder = _defaultFieldViewBuilder,
this.onSelected,
this.optionsMaxHeight = 200.0,
this.optionsViewBuilder,
this.initialValue,
super.key,
});
final AutocompleteOptionToWidget<T> displayWidgetForOption;
final AutocompleteFieldViewBuilder fieldViewBuilder;
final AutocompleteOnSelected<T>? onSelected;
final AutocompleteOptionsBuilder<T> optionsBuilder;
final AutocompleteOptionsViewBuilder<T>? optionsViewBuilder;
final double optionsMaxHeight;
final TextEditingValue? initialValue;
static Widget _defaultFieldViewBuilder(
final BuildContext context,
final TextEditingController textEditingController,
final FocusNode focusNode,
final VoidCallback onFieldSubmitted,
) =>
_CustomAutocompleteField(
focusNode: focusNode,
textEditingController: textEditingController,
onFieldSubmitted: onFieldSubmitted,
);
@override
Widget build(final BuildContext context) => CustomRawAutocomplete<T>(
displayWidgetForOption: displayWidgetForOption,
fieldViewBuilder: fieldViewBuilder,
initialValue: initialValue,
optionsBuilder: optionsBuilder,
optionsViewBuilder: optionsViewBuilder ??
(
final context,
final onSelected,
final options,
) =>
_CustomAutocompleteOptions<T>(
displayWidgetForOption: displayWidgetForOption,
onSelected: onSelected,
options: options,
maxOptionsHeight: optionsMaxHeight,
),
onSelected: onSelected,
);
}
// The default Material-style Autocomplete text field.
class _CustomAutocompleteField extends StatelessWidget {
const _CustomAutocompleteField({
required this.focusNode,
required this.textEditingController,
required this.onFieldSubmitted,
});
final FocusNode focusNode;
final VoidCallback onFieldSubmitted;
final TextEditingController textEditingController;
@override
Widget build(final BuildContext context) => TextFormField(
controller: textEditingController,
focusNode: focusNode,
onFieldSubmitted: (final value) {
onFieldSubmitted();
},
);
}
// The default Material-style Autocomplete options.
class _CustomAutocompleteOptions<T extends Object> extends StatelessWidget {
const _CustomAutocompleteOptions({
required this.displayWidgetForOption,
required this.onSelected,
required this.options,
required this.maxOptionsHeight,
super.key,
});
final AutocompleteOptionToWidget<T> displayWidgetForOption;
final AutocompleteOnSelected<T> onSelected;
final Iterable<T> options;
final double maxOptionsHeight;
@override
Widget build(final BuildContext context) => Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxOptionsHeight),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (final context, final index) {
final option = options.elementAt(index);
return InkWell(
onTap: () {
onSelected(option);
},
child: Builder(
builder: (final context) {
final highlight = AutocompleteHighlightedOption.of(context) == index;
if (highlight) {
SchedulerBinding.instance.addPostFrameCallback((final timeStamp) async {
await Scrollable.ensureVisible(context, alignment: 0.5);
});
}
return Container(
color: highlight ? Theme.of(context).focusColor : null,
padding: const EdgeInsets.all(16),
child: displayWidgetForOption(option),
);
},
),
);
},
),
),
),
);
}
typedef AutocompleteOptionToWidget<T extends Object> = Widget Function(T option);
class CustomRawAutocomplete<T extends Object> extends StatefulWidget {
const CustomRawAutocomplete({
required this.optionsViewBuilder,
required this.optionsBuilder,
this.displayWidgetForOption = defaultWidgetForOption,
this.fieldViewBuilder,
this.focusNode,
this.onSelected,
this.textEditingController,
this.initialValue,
super.key,
}) : 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.',
),
// ignore: prefer_asserts_with_message
assert((focusNode == null) == (textEditingController == null)),
assert(
!(textEditingController != null && initialValue != null),
'textEditingController and initialValue cannot be simultaneously defined.',
);
final AutocompleteFieldViewBuilder? fieldViewBuilder;
final FocusNode? focusNode;
final AutocompleteOptionsViewBuilder<T> optionsViewBuilder;
final AutocompleteOptionToWidget<T> displayWidgetForOption;
final AutocompleteOnSelected<T>? onSelected;
final AutocompleteOptionsBuilder<T> optionsBuilder;
final TextEditingController? textEditingController;
final TextEditingValue? initialValue;
static void onFieldSubmitted<T extends Object>(final GlobalKey key) {
(key.currentState! as _CustomRawAutocompleteState<T>)._onFieldSubmitted();
}
static Widget defaultWidgetForOption(final dynamic option) => Text(option.toString());
@override
State<CustomRawAutocomplete<T>> createState() => _CustomRawAutocompleteState<T>();
}
class _CustomRawAutocompleteState<T extends Object> extends State<CustomRawAutocomplete<T>> {
final GlobalKey _fieldKey = GlobalKey();
final LayerLink _optionsLayerLink = LayerLink();
late TextEditingController _textEditingController;
late FocusNode _focusNode;
late final Map<Type, Action<Intent>> _actionMap;
late final _CustomAutocompleteCallbackAction<AutocompletePreviousOptionIntent> _previousOptionAction;
late final _CustomAutocompleteCallbackAction<AutocompleteNextOptionIntent> _nextOptionAction;
late final _CustomAutocompleteCallbackAction<DismissIntent> _hideOptionsAction;
Iterable<T> _options = Iterable<T>.empty();
T? _selection;
bool _userHidOptions = false;
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(),
};
// The OverlayEntry containing the options.
OverlayEntry? _floatingOptions;
// True iff the state indicates that the options should be visible.
bool get _shouldShowOptions => !_userHidOptions && _focusNode.hasFocus && _selection == null && _options.isNotEmpty;
// Called when _textEditingController changes.
Future<void> _onChangedField() async {
final value = _textEditingController.value;
final options = await widget.optionsBuilder(
value,
);
_options = options;
_updateHighlight(_highlightedOptionIndex.value);
if (_selection != null && value.text != _selection!.toString()) {
_selection = null;
}
// Make sure the options are no longer hidden if the content of the field
// changes (ignore selection changes).
if (value.text != _lastFieldText) {
_userHidOptions = false;
_lastFieldText = value.text;
}
_updateActions();
_updateOverlay();
}
// Called when the field's FocusNode changes.
void _onChangedFocus() {
// Options should no longer be hidden when the field is re-focused.
_userHidOptions = !_focusNode.hasFocus;
_updateActions();
_updateOverlay();
}
// Called from fieldViewBuilder when the user submits the field.
void _onFieldSubmitted() {
if (_options.isEmpty || _userHidOptions) {
return;
}
_select(_options.elementAt(_highlightedOptionIndex.value));
}
// Select the given option and update the widget.
void _select(final T nextSelection) {
if (nextSelection == _selection) {
return;
}
_selection = nextSelection;
final selectionString = nextSelection.toString();
_textEditingController.value = TextEditingValue(
selection: TextSelection.collapsed(offset: selectionString.length),
text: selectionString,
);
_updateActions();
_updateOverlay();
widget.onSelected?.call(_selection!);
}
void _updateHighlight(final int newIndex) {
_highlightedOptionIndex.value = _options.isEmpty ? 0 : newIndex % _options.length;
}
void _highlightPreviousOption(final AutocompletePreviousOptionIntent intent) {
if (_userHidOptions) {
_userHidOptions = false;
_updateActions();
_updateOverlay();
return;
}
_updateHighlight(_highlightedOptionIndex.value - 1);
}
void _highlightNextOption(final AutocompleteNextOptionIntent intent) {
if (_userHidOptions) {
_userHidOptions = false;
_updateActions();
_updateOverlay();
return;
}
_updateHighlight(_highlightedOptionIndex.value + 1);
}
Object? _hideOptions(final DismissIntent intent) {
if (!_userHidOptions) {
_userHidOptions = true;
_updateActions();
_updateOverlay();
return null;
}
return Actions.invoke(context, intent);
}
void _setActionsEnabled(final bool enabled) {
// 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.
_previousOptionAction.enabled = enabled;
_nextOptionAction.enabled = enabled;
_hideOptionsAction.enabled = enabled;
}
void _updateActions() {
_setActionsEnabled(_focusNode.hasFocus && _selection == null && _options.isNotEmpty);
}
bool _floatingOptionsUpdateScheduled = false;
// Hide or show the options overlay, if needed.
void _updateOverlay() {
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) {
if (!_floatingOptionsUpdateScheduled) {
_floatingOptionsUpdateScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((final timeStamp) {
_floatingOptionsUpdateScheduled = false;
_updateOverlay();
});
}
return;
}
_floatingOptions?.remove();
if (_shouldShowOptions) {
final newFloatingOptions = OverlayEntry(
builder: (final context) => CompositedTransformFollower(
link: _optionsLayerLink,
showWhenUnlinked: false,
targetAnchor: Alignment.bottomLeft,
child: AutocompleteHighlightedOption(
highlightIndexNotifier: _highlightedOptionIndex,
child: Builder(
builder: (final context) => widget.optionsViewBuilder(
context,
_select,
_options,
),
),
),
),
);
Overlay.of(context, rootOverlay: true)!.insert(newFloatingOptions);
_floatingOptions = newFloatingOptions;
} else {
_floatingOptions = null;
}
}
// Handle a potential change in textEditingController by properly disposing of
// the old one and setting up the new one, if needed.
void _updateTextEditingController(final TextEditingController? old, final TextEditingController? current) {
if ((old == null && current == null) || old == current) {
return;
}
if (old == null) {
_textEditingController
..removeListener(_onChangedField)
..dispose();
_textEditingController = current!;
} else if (current == null) {
_textEditingController.removeListener(_onChangedField);
_textEditingController = TextEditingController();
} else {
_textEditingController.removeListener(_onChangedField);
_textEditingController = current;
}
_textEditingController.addListener(_onChangedField);
}
// Handle a potential change in focusNode by properly disposing of the old one
// and setting up the new one, if needed.
void _updateFocusNode(final FocusNode? old, final FocusNode? current) {
if ((old == null && current == null) || old == current) {
return;
}
if (old == null) {
_focusNode
..removeListener(_onChangedFocus)
..dispose();
_focusNode = current!;
} else if (current == null) {
_focusNode.removeListener(_onChangedFocus);
_focusNode = FocusNode();
} else {
_focusNode.removeListener(_onChangedFocus);
_focusNode = current;
}
_focusNode.addListener(_onChangedFocus);
}
@override
void initState() {
super.initState();
_textEditingController = widget.textEditingController ?? TextEditingController.fromValue(widget.initialValue);
_textEditingController.addListener(_onChangedField);
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onChangedFocus);
_previousOptionAction =
_CustomAutocompleteCallbackAction<AutocompletePreviousOptionIntent>(onInvoke: _highlightPreviousOption);
_nextOptionAction = _CustomAutocompleteCallbackAction<AutocompleteNextOptionIntent>(onInvoke: _highlightNextOption);
_hideOptionsAction = _CustomAutocompleteCallbackAction<DismissIntent>(onInvoke: _hideOptions);
_actionMap = <Type, Action<Intent>>{
AutocompletePreviousOptionIntent: _previousOptionAction,
AutocompleteNextOptionIntent: _nextOptionAction,
DismissIntent: _hideOptionsAction,
};
_updateActions();
_updateOverlay();
}
@override
void didUpdateWidget(final CustomRawAutocomplete<T> oldWidget) {
super.didUpdateWidget(oldWidget);
_updateTextEditingController(
oldWidget.textEditingController,
widget.textEditingController,
);
_updateFocusNode(oldWidget.focusNode, widget.focusNode);
_updateActions();
_updateOverlay();
}
@override
void dispose() {
_textEditingController.removeListener(_onChangedField);
if (widget.textEditingController == null) {
_textEditingController.dispose();
}
_focusNode.removeListener(_onChangedFocus);
if (widget.focusNode == null) {
_focusNode.dispose();
}
_floatingOptions?.remove();
_floatingOptions = null;
super.dispose();
}
@override
Widget build(final BuildContext context) => Container(
key: _fieldKey,
child: Shortcuts(
shortcuts: _shortcuts,
child: Actions(
actions: _actionMap,
child: CompositedTransformTarget(
link: _optionsLayerLink,
child: widget.fieldViewBuilder == null
? const SizedBox.shrink()
: widget.fieldViewBuilder!(
context,
_textEditingController,
_focusNode,
_onFieldSubmitted,
),
),
),
),
);
}
class _CustomAutocompleteCallbackAction<T extends Intent> extends CallbackAction<T> {
_CustomAutocompleteCallbackAction({
required super.onInvoke,
this.enabled = true,
});
bool enabled;
@override
bool isEnabled(covariant final T intent) => enabled;
@override
bool consumesKey(covariant final T intent) => enabled;
}
Loading…
Cancel
Save