mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-09 14:08:00 -06:00
feat: Improve container opening animation, improve scrolling on details page
This commit is contained in:
291
lib/core/widgets/material/chips_input.dart
Normal file
291
lib/core/widgets/material/chips_input.dart
Normal file
@@ -0,0 +1,291 @@
|
||||
// MIT License
|
||||
//
|
||||
// Copyright (c) 2019 Simon Lightfoot
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
//
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
typedef ChipsInputSuggestions<T> = Future<List<T>> Function(String query);
|
||||
typedef ChipSelected<T> = void Function(T data, bool selected);
|
||||
typedef ChipsBuilder<T> = Widget Function(
|
||||
BuildContext context, ChipsInputState<T> state, T data);
|
||||
|
||||
class ChipsInput<T> extends StatefulWidget {
|
||||
const ChipsInput({
|
||||
super.key,
|
||||
this.decoration = const InputDecoration(),
|
||||
required this.chipBuilder,
|
||||
required this.suggestionBuilder,
|
||||
required this.findSuggestions,
|
||||
required this.onChanged,
|
||||
this.onChipTapped,
|
||||
});
|
||||
|
||||
final InputDecoration decoration;
|
||||
final ChipsInputSuggestions<T> findSuggestions;
|
||||
final ValueChanged<List<T>> onChanged;
|
||||
final ValueChanged<T>? onChipTapped;
|
||||
final ChipsBuilder<T> chipBuilder;
|
||||
final ChipsBuilder<T> suggestionBuilder;
|
||||
|
||||
@override
|
||||
ChipsInputState<T> createState() => ChipsInputState<T>();
|
||||
}
|
||||
|
||||
class ChipsInputState<T> extends State<ChipsInput<T>> {
|
||||
static const kObjectReplacementChar = 0xFFFC;
|
||||
|
||||
Set<T> _chips = {};
|
||||
List<T> _suggestions = [];
|
||||
int _searchId = 0;
|
||||
|
||||
FocusNode _focusNode = FocusNode();
|
||||
TextEditingValue _value = const TextEditingValue();
|
||||
TextInputConnection? _connection;
|
||||
|
||||
String get text {
|
||||
return String.fromCharCodes(
|
||||
_value.text.codeUnits.where((ch) => ch != kObjectReplacementChar),
|
||||
);
|
||||
}
|
||||
|
||||
TextEditingValue get currentTextEditingValue => _value;
|
||||
|
||||
bool get _hasInputConnection =>
|
||||
_connection != null && (_connection?.attached ?? false);
|
||||
|
||||
void requestKeyboard() {
|
||||
if (_focusNode.hasFocus) {
|
||||
_openInputConnection();
|
||||
} else {
|
||||
FocusScope.of(context).requestFocus(_focusNode);
|
||||
}
|
||||
}
|
||||
|
||||
void selectSuggestion(T data) {
|
||||
setState(() {
|
||||
_chips.add(data);
|
||||
_updateTextInputState();
|
||||
_suggestions = [];
|
||||
});
|
||||
widget.onChanged(_chips.toList(growable: false));
|
||||
}
|
||||
|
||||
void deleteChip(T data) {
|
||||
setState(() {
|
||||
_chips.remove(data);
|
||||
_updateTextInputState();
|
||||
});
|
||||
widget.onChanged(_chips.toList(growable: false));
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_focusNode.addListener(_onFocusChanged);
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
if (_focusNode.hasFocus) {
|
||||
_openInputConnection();
|
||||
} else {
|
||||
_closeInputConnectionIfNeeded();
|
||||
}
|
||||
setState(() {
|
||||
// rebuild so that _TextCursor is hidden.
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
_closeInputConnectionIfNeeded();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _openInputConnection() {
|
||||
if (!_hasInputConnection) {
|
||||
_connection?.setEditingState(_value);
|
||||
}
|
||||
_connection?.show();
|
||||
}
|
||||
|
||||
void _closeInputConnectionIfNeeded() {
|
||||
if (_hasInputConnection) {
|
||||
_connection?.close();
|
||||
_connection = null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chipsChildren = _chips
|
||||
.map<Widget>(
|
||||
(data) => widget.chipBuilder(context, this, data),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
chipsChildren.add(
|
||||
SizedBox(
|
||||
height: 32.0,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
text,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
_TextCaret(
|
||||
resumed: _focusNode.hasFocus,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
//mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: requestKeyboard,
|
||||
child: InputDecorator(
|
||||
decoration: widget.decoration,
|
||||
isFocused: _focusNode.hasFocus,
|
||||
isEmpty: _value.text.isEmpty,
|
||||
child: Wrap(
|
||||
children: chipsChildren,
|
||||
spacing: 4.0,
|
||||
runSpacing: 4.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _suggestions.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return widget.suggestionBuilder(
|
||||
context, this, _suggestions[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void updateEditingValue(TextEditingValue value) {
|
||||
final oldCount = _countReplacements(_value);
|
||||
final newCount = _countReplacements(value);
|
||||
setState(() {
|
||||
if (newCount < oldCount) {
|
||||
_chips = Set.from(_chips.take(newCount));
|
||||
}
|
||||
_value = value;
|
||||
});
|
||||
_onSearchChanged(text);
|
||||
}
|
||||
|
||||
int _countReplacements(TextEditingValue value) {
|
||||
return value.text.codeUnits
|
||||
.where((ch) => ch == kObjectReplacementChar)
|
||||
.length;
|
||||
}
|
||||
|
||||
void _updateTextInputState() {
|
||||
final text =
|
||||
String.fromCharCodes(_chips.map((_) => kObjectReplacementChar));
|
||||
_value = TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: text.length),
|
||||
composing: TextRange(start: 0, end: text.length),
|
||||
);
|
||||
_connection?.setEditingState(_value);
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) async {
|
||||
final localId = ++_searchId;
|
||||
final results = await widget.findSuggestions(value);
|
||||
if (_searchId == localId && mounted) {
|
||||
setState(() => _suggestions = results
|
||||
.where((profile) => !_chips.contains(profile))
|
||||
.toList(growable: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _TextCaret extends StatefulWidget {
|
||||
const _TextCaret({
|
||||
this.duration = const Duration(milliseconds: 500),
|
||||
this.resumed = false,
|
||||
});
|
||||
|
||||
final Duration duration;
|
||||
final bool resumed;
|
||||
|
||||
@override
|
||||
_TextCursorState createState() => _TextCursorState();
|
||||
}
|
||||
|
||||
class _TextCursorState extends State<_TextCaret>
|
||||
with SingleTickerProviderStateMixin {
|
||||
bool _displayed = false;
|
||||
late Timer _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(widget.duration, _onTimer);
|
||||
}
|
||||
|
||||
void _onTimer(Timer timer) {
|
||||
setState(() => _displayed = !_displayed);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return FractionallySizedBox(
|
||||
heightFactor: 0.7,
|
||||
child: Opacity(
|
||||
opacity: _displayed && widget.resumed ? 1.0 : 0.0,
|
||||
child: Container(
|
||||
width: 2.0,
|
||||
color: theme.primaryColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user