mirror of
https://github.com/HChaZZY/PiliPlus.git
synced 2025-12-06 09:13:48 +08:00
feat: at user
Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
|
||||
## feat
|
||||
|
||||
- [x] 动态/评论@用户
|
||||
- [x] 修改消息设置
|
||||
- [x] 修改聊天设置
|
||||
- [x] 展示折叠消息
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
// 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.
|
||||
|
||||
// ignore_for_file: uri_does_not_exist_in_doc_import
|
||||
|
||||
/// @docImport 'selectable_text.dart';
|
||||
/// @docImport 'selection_area.dart';
|
||||
/// @docImport 'text_field.dart';
|
||||
library;
|
||||
|
||||
import 'package:PiliPlus/common/widgets/text_field/editable_text.dart';
|
||||
import 'package:flutter/cupertino.dart' hide EditableText, EditableTextState;
|
||||
import 'package:flutter/material.dart' hide EditableText, EditableTextState;
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
/// The default context menu for text selection for the current platform.
|
||||
///
|
||||
/// {@template flutter.material.AdaptiveTextSelectionToolbar.contextMenuBuilders}
|
||||
/// Typically, this widget would be passed to `contextMenuBuilder` in a
|
||||
/// supported parent widget, such as:
|
||||
///
|
||||
/// * [EditableText.contextMenuBuilder]
|
||||
/// * [TextField.contextMenuBuilder]
|
||||
/// * [CupertinoTextField.contextMenuBuilder]
|
||||
/// * [SelectionArea.contextMenuBuilder]
|
||||
/// * [SelectableText.contextMenuBuilder]
|
||||
/// {@endtemplate}
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [EditableText.getEditableButtonItems], which returns the default
|
||||
/// [ContextMenuButtonItem]s for [EditableText] on the platform.
|
||||
/// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button
|
||||
/// Widgets for the current platform given [ContextMenuButtonItem]s.
|
||||
/// * [CupertinoAdaptiveTextSelectionToolbar], which does the same thing as this
|
||||
/// widget but only for Cupertino context menus.
|
||||
/// * [TextSelectionToolbar], the default toolbar for Android.
|
||||
/// * [DesktopTextSelectionToolbar], the default toolbar for desktop platforms
|
||||
/// other than MacOS.
|
||||
/// * [CupertinoTextSelectionToolbar], the default toolbar for iOS.
|
||||
/// * [CupertinoDesktopTextSelectionToolbar], the default toolbar for MacOS.
|
||||
class AdaptiveTextSelectionToolbar extends StatelessWidget {
|
||||
/// Create an instance of [AdaptiveTextSelectionToolbar] with the
|
||||
/// given [children].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// {@template flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
|
||||
/// * [AdaptiveTextSelectionToolbar.buttonItems], which takes a list of
|
||||
/// [ContextMenuButtonItem]s instead of [children] widgets.
|
||||
/// {@endtemplate}
|
||||
/// {@template flutter.material.AdaptiveTextSelectionToolbar.editable}
|
||||
/// * [AdaptiveTextSelectionToolbar.editable], which builds the default
|
||||
/// children for an editable field.
|
||||
/// {@endtemplate}
|
||||
/// {@template flutter.material.AdaptiveTextSelectionToolbar.editableText}
|
||||
/// * [AdaptiveTextSelectionToolbar.editableText], which builds the default
|
||||
/// children for an [EditableText].
|
||||
/// {@endtemplate}
|
||||
/// {@template flutter.material.AdaptiveTextSelectionToolbar.selectable}
|
||||
/// * [AdaptiveTextSelectionToolbar.selectable], which builds the default
|
||||
/// children for content that is selectable but not editable.
|
||||
/// {@endtemplate}
|
||||
const AdaptiveTextSelectionToolbar(
|
||||
{super.key, required this.children, required this.anchors})
|
||||
: buttonItems = null;
|
||||
|
||||
/// Create an instance of [AdaptiveTextSelectionToolbar] whose children will
|
||||
/// be built from the given [buttonItems].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// {@template flutter.material.AdaptiveTextSelectionToolbar.new}
|
||||
/// * [AdaptiveTextSelectionToolbar.new], which takes the children directly as
|
||||
/// a list of widgets.
|
||||
/// {@endtemplate}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
|
||||
const AdaptiveTextSelectionToolbar.buttonItems({
|
||||
super.key,
|
||||
required this.buttonItems,
|
||||
required this.anchors,
|
||||
}) : children = null;
|
||||
|
||||
/// Create an instance of [AdaptiveTextSelectionToolbar] with the default
|
||||
/// children for an editable field.
|
||||
///
|
||||
/// If an on* callback parameter is null, then its corresponding button will
|
||||
/// not be built.
|
||||
///
|
||||
/// These callbacks are called when their corresponding button is activated
|
||||
/// and only then. For example, `onPaste` is called when the user taps the
|
||||
/// "Paste" button in the context menu and not when the user pastes with the
|
||||
/// keyboard.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
|
||||
AdaptiveTextSelectionToolbar.editable({
|
||||
super.key,
|
||||
required ClipboardStatus clipboardStatus,
|
||||
required VoidCallback? onCopy,
|
||||
required VoidCallback? onCut,
|
||||
required VoidCallback? onPaste,
|
||||
required VoidCallback? onSelectAll,
|
||||
required VoidCallback? onLookUp,
|
||||
required VoidCallback? onSearchWeb,
|
||||
required VoidCallback? onShare,
|
||||
required VoidCallback? onLiveTextInput,
|
||||
required this.anchors,
|
||||
}) : children = null,
|
||||
buttonItems = EditableText.getEditableButtonItems(
|
||||
clipboardStatus: clipboardStatus,
|
||||
onCopy: onCopy,
|
||||
onCut: onCut,
|
||||
onPaste: onPaste,
|
||||
onSelectAll: onSelectAll,
|
||||
onLookUp: onLookUp,
|
||||
onSearchWeb: onSearchWeb,
|
||||
onShare: onShare,
|
||||
onLiveTextInput: onLiveTextInput,
|
||||
);
|
||||
|
||||
/// Create an instance of [AdaptiveTextSelectionToolbar] with the default
|
||||
/// children for an [EditableText].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
|
||||
AdaptiveTextSelectionToolbar.editableText({
|
||||
super.key,
|
||||
required EditableTextState editableTextState,
|
||||
}) : children = null,
|
||||
buttonItems = editableTextState.contextMenuButtonItems,
|
||||
anchors = editableTextState.contextMenuAnchors;
|
||||
|
||||
/// Create an instance of [AdaptiveTextSelectionToolbar] with the default
|
||||
/// children for selectable, but not editable, content.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
|
||||
AdaptiveTextSelectionToolbar.selectable({
|
||||
super.key,
|
||||
required VoidCallback onCopy,
|
||||
required VoidCallback onSelectAll,
|
||||
required VoidCallback? onShare,
|
||||
required SelectionGeometry selectionGeometry,
|
||||
required this.anchors,
|
||||
}) : children = null,
|
||||
buttonItems = SelectableRegion.getSelectableButtonItems(
|
||||
selectionGeometry: selectionGeometry,
|
||||
onCopy: onCopy,
|
||||
onSelectAll: onSelectAll,
|
||||
onShare: onShare,
|
||||
);
|
||||
|
||||
/// Create an instance of [AdaptiveTextSelectionToolbar] with the default
|
||||
/// children for a [SelectableRegion].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
|
||||
AdaptiveTextSelectionToolbar.selectableRegion({
|
||||
super.key,
|
||||
required SelectableRegionState selectableRegionState,
|
||||
}) : children = null,
|
||||
buttonItems = selectableRegionState.contextMenuButtonItems,
|
||||
anchors = selectableRegionState.contextMenuAnchors;
|
||||
|
||||
/// {@template flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
|
||||
/// The [ContextMenuButtonItem]s that will be turned into the correct button
|
||||
/// widgets for the current platform.
|
||||
/// {@endtemplate}
|
||||
final List<ContextMenuButtonItem>? buttonItems;
|
||||
|
||||
/// The children of the toolbar, typically buttons.
|
||||
final List<Widget>? children;
|
||||
|
||||
/// {@template flutter.material.AdaptiveTextSelectionToolbar.anchors}
|
||||
/// The location on which to anchor the menu.
|
||||
/// {@endtemplate}
|
||||
final TextSelectionToolbarAnchors anchors;
|
||||
|
||||
/// Returns the default button label String for the button of the given
|
||||
/// [ContextMenuButtonType] on any platform.
|
||||
static String getButtonLabel(
|
||||
BuildContext context, ContextMenuButtonItem buttonItem) {
|
||||
if (buttonItem.label != null) {
|
||||
return buttonItem.label!;
|
||||
}
|
||||
|
||||
switch (Theme.of(context).platform) {
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
return CupertinoTextSelectionToolbarButton.getButtonLabel(
|
||||
context, buttonItem);
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.windows:
|
||||
assert(debugCheckHasMaterialLocalizations(context));
|
||||
final MaterialLocalizations localizations =
|
||||
MaterialLocalizations.of(context);
|
||||
return switch (buttonItem.type) {
|
||||
ContextMenuButtonType.cut => localizations.cutButtonLabel,
|
||||
ContextMenuButtonType.copy => localizations.copyButtonLabel,
|
||||
ContextMenuButtonType.paste => localizations.pasteButtonLabel,
|
||||
ContextMenuButtonType.selectAll => localizations.selectAllButtonLabel,
|
||||
ContextMenuButtonType.delete =>
|
||||
localizations.deleteButtonTooltip.toUpperCase(),
|
||||
ContextMenuButtonType.lookUp => localizations.lookUpButtonLabel,
|
||||
ContextMenuButtonType.searchWeb => localizations.searchWebButtonLabel,
|
||||
ContextMenuButtonType.share => localizations.shareButtonLabel,
|
||||
ContextMenuButtonType.liveTextInput =>
|
||||
localizations.scanTextButtonLabel,
|
||||
ContextMenuButtonType.custom => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a List of Widgets generated by turning [buttonItems] into the
|
||||
/// default context menu buttons for the current platform.
|
||||
///
|
||||
/// This is useful when building a text selection toolbar with the default
|
||||
/// button appearance for the given platform, but where the toolbar and/or the
|
||||
/// button actions and labels may be custom.
|
||||
///
|
||||
/// {@tool dartpad}
|
||||
/// This sample demonstrates how to use `getAdaptiveButtons` to generate
|
||||
/// default button widgets in a custom toolbar.
|
||||
///
|
||||
/// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.2.dart **
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons], which is the
|
||||
/// Cupertino equivalent of this class and builds only the Cupertino
|
||||
/// buttons.
|
||||
static Iterable<Widget> getAdaptiveButtons(
|
||||
BuildContext context,
|
||||
List<ContextMenuButtonItem> buttonItems,
|
||||
) {
|
||||
switch (Theme.of(context).platform) {
|
||||
case TargetPlatform.iOS:
|
||||
return buttonItems.map((ContextMenuButtonItem buttonItem) {
|
||||
return CupertinoTextSelectionToolbarButton.buttonItem(
|
||||
buttonItem: buttonItem);
|
||||
});
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.android:
|
||||
final List<Widget> buttons = <Widget>[];
|
||||
for (int i = 0; i < buttonItems.length; i++) {
|
||||
final ContextMenuButtonItem buttonItem = buttonItems[i];
|
||||
buttons.add(
|
||||
TextSelectionToolbarTextButton(
|
||||
padding: TextSelectionToolbarTextButton.getPadding(
|
||||
i, buttonItems.length),
|
||||
onPressed: buttonItem.onPressed,
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(getButtonLabel(context, buttonItem)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return buttons;
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.windows:
|
||||
return buttonItems.map((ContextMenuButtonItem buttonItem) {
|
||||
return DesktopTextSelectionToolbarButton.text(
|
||||
context: context,
|
||||
onPressed: buttonItem.onPressed,
|
||||
text: getButtonLabel(context, buttonItem),
|
||||
);
|
||||
});
|
||||
case TargetPlatform.macOS:
|
||||
return buttonItems.map((ContextMenuButtonItem buttonItem) {
|
||||
return CupertinoDesktopTextSelectionToolbarButton.text(
|
||||
onPressed: buttonItem.onPressed,
|
||||
text: getButtonLabel(context, buttonItem),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// If there aren't any buttons to build, build an empty toolbar.
|
||||
if ((children != null && children!.isEmpty) ||
|
||||
(buttonItems != null && buttonItems!.isEmpty)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final List<Widget> resultChildren = children != null
|
||||
? children!
|
||||
: getAdaptiveButtons(context, buttonItems!).toList();
|
||||
|
||||
switch (Theme.of(context).platform) {
|
||||
case TargetPlatform.iOS:
|
||||
return CupertinoTextSelectionToolbar(
|
||||
anchorAbove: anchors.primaryAnchor,
|
||||
anchorBelow: anchors.secondaryAnchor == null
|
||||
? anchors.primaryAnchor
|
||||
: anchors.secondaryAnchor!,
|
||||
children: resultChildren,
|
||||
);
|
||||
case TargetPlatform.android:
|
||||
return TextSelectionToolbar(
|
||||
anchorAbove: anchors.primaryAnchor,
|
||||
anchorBelow: anchors.secondaryAnchor == null
|
||||
? anchors.primaryAnchor
|
||||
: anchors.secondaryAnchor!,
|
||||
children: resultChildren,
|
||||
);
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.windows:
|
||||
return DesktopTextSelectionToolbar(
|
||||
anchor: anchors.primaryAnchor, children: resultChildren);
|
||||
case TargetPlatform.macOS:
|
||||
return CupertinoDesktopTextSelectionToolbar(
|
||||
anchor: anchors.primaryAnchor,
|
||||
children: resultChildren,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// 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.
|
||||
|
||||
/// @docImport 'package:flutter/material.dart';
|
||||
library;
|
||||
|
||||
import 'package:PiliPlus/common/widgets/text_field/editable_text.dart';
|
||||
import 'package:flutter/cupertino.dart' hide EditableText, EditableTextState;
|
||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
/// The default Cupertino context menu for text selection for the current
|
||||
/// platform with the given children.
|
||||
///
|
||||
/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.platforms}
|
||||
/// Builds the mobile Cupertino context menu on all mobile platforms, not just
|
||||
/// iOS, and builds the desktop Cupertino context menu on all desktop platforms,
|
||||
/// not just MacOS. For a widget that builds the native-looking context menu for
|
||||
/// all platforms, see [AdaptiveTextSelectionToolbar].
|
||||
/// {@endtemplate}
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [AdaptiveTextSelectionToolbar], which does the same thing as this widget
|
||||
/// but for all platforms, not just the Cupertino-styled platforms.
|
||||
/// * [CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds
|
||||
/// the Cupertino button Widgets for the current platform given
|
||||
/// [ContextMenuButtonItem]s.
|
||||
class CupertinoAdaptiveTextSelectionToolbar extends StatelessWidget {
|
||||
/// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
|
||||
/// given [children].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
|
||||
/// * [CupertinoAdaptiveTextSelectionToolbar.buttonItems], which takes a list
|
||||
/// of [ContextMenuButtonItem]s instead of [children] widgets.
|
||||
/// {@endtemplate}
|
||||
/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
|
||||
/// * [CupertinoAdaptiveTextSelectionToolbar.editable], which builds the
|
||||
/// default Cupertino children for an editable field.
|
||||
/// {@endtemplate}
|
||||
/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
|
||||
/// * [CupertinoAdaptiveTextSelectionToolbar.editableText], which builds the
|
||||
/// default Cupertino children for an [EditableText].
|
||||
/// {@endtemplate}
|
||||
/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
|
||||
/// * [CupertinoAdaptiveTextSelectionToolbar.selectable], which builds the
|
||||
/// Cupertino children for content that is selectable but not editable.
|
||||
/// {@endtemplate}
|
||||
const CupertinoAdaptiveTextSelectionToolbar({
|
||||
super.key,
|
||||
required this.children,
|
||||
required this.anchors,
|
||||
}) : buttonItems = null;
|
||||
|
||||
/// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] whose
|
||||
/// children will be built from the given [buttonItems].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
|
||||
/// * [CupertinoAdaptiveTextSelectionToolbar.new], which takes the children
|
||||
/// directly as a list of widgets.
|
||||
/// {@endtemplate}
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
|
||||
const CupertinoAdaptiveTextSelectionToolbar.buttonItems({
|
||||
super.key,
|
||||
required this.buttonItems,
|
||||
required this.anchors,
|
||||
}) : children = null;
|
||||
|
||||
/// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
|
||||
/// default children for an editable field.
|
||||
///
|
||||
/// If a callback is null, then its corresponding button will not be built.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [AdaptiveTextSelectionToolbar.editable], which is similar to this but
|
||||
/// includes Material and Cupertino toolbars.
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
|
||||
CupertinoAdaptiveTextSelectionToolbar.editable({
|
||||
super.key,
|
||||
required ClipboardStatus clipboardStatus,
|
||||
required VoidCallback? onCopy,
|
||||
required VoidCallback? onCut,
|
||||
required VoidCallback? onPaste,
|
||||
required VoidCallback? onSelectAll,
|
||||
required VoidCallback? onLookUp,
|
||||
required VoidCallback? onSearchWeb,
|
||||
required VoidCallback? onShare,
|
||||
required VoidCallback? onLiveTextInput,
|
||||
required this.anchors,
|
||||
}) : children = null,
|
||||
buttonItems = EditableText.getEditableButtonItems(
|
||||
clipboardStatus: clipboardStatus,
|
||||
onCopy: onCopy,
|
||||
onCut: onCut,
|
||||
onPaste: onPaste,
|
||||
onSelectAll: onSelectAll,
|
||||
onLookUp: onLookUp,
|
||||
onSearchWeb: onSearchWeb,
|
||||
onShare: onShare,
|
||||
onLiveTextInput: onLiveTextInput,
|
||||
);
|
||||
|
||||
/// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
|
||||
/// default children for an [EditableText].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [AdaptiveTextSelectionToolbar.editableText], which is similar to this
|
||||
/// but includes Material and Cupertino toolbars.
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
|
||||
CupertinoAdaptiveTextSelectionToolbar.editableText({
|
||||
super.key,
|
||||
required EditableTextState editableTextState,
|
||||
}) : children = null,
|
||||
buttonItems = editableTextState.contextMenuButtonItems,
|
||||
anchors = editableTextState.contextMenuAnchors;
|
||||
|
||||
/// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
|
||||
/// default children for selectable, but not editable, content.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [AdaptiveTextSelectionToolbar.selectable], which is similar to this but
|
||||
/// includes Material and Cupertino toolbars.
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
|
||||
/// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
|
||||
CupertinoAdaptiveTextSelectionToolbar.selectable({
|
||||
super.key,
|
||||
required VoidCallback onCopy,
|
||||
required VoidCallback onSelectAll,
|
||||
required SelectionGeometry selectionGeometry,
|
||||
required this.anchors,
|
||||
}) : children = null,
|
||||
buttonItems = SelectableRegion.getSelectableButtonItems(
|
||||
selectionGeometry: selectionGeometry,
|
||||
onCopy: onCopy,
|
||||
onSelectAll: onSelectAll,
|
||||
onShare:
|
||||
null, // See https://github.com/flutter/flutter/issues/141775.
|
||||
);
|
||||
|
||||
/// {@macro flutter.material.AdaptiveTextSelectionToolbar.anchors}
|
||||
final TextSelectionToolbarAnchors anchors;
|
||||
|
||||
/// The children of the toolbar, typically buttons.
|
||||
final List<Widget>? children;
|
||||
|
||||
/// The [ContextMenuButtonItem]s that will be turned into the correct button
|
||||
/// widgets for the current platform.
|
||||
final List<ContextMenuButtonItem>? buttonItems;
|
||||
|
||||
/// Returns a List of Widgets generated by turning [buttonItems] into the
|
||||
/// default context menu buttons for Cupertino on the current platform.
|
||||
///
|
||||
/// This is useful when building a text selection toolbar with the default
|
||||
/// button appearance for the given platform, but where the toolbar and/or the
|
||||
/// button actions and labels may be custom.
|
||||
///
|
||||
/// Does not build Material buttons. On non-Apple platforms, Cupertino buttons
|
||||
/// will still be used, because the Cupertino library does not access the
|
||||
/// Material library. To get the native-looking buttons on every platform,
|
||||
/// use [AdaptiveTextSelectionToolbar.getAdaptiveButtons] in the Material
|
||||
/// library.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which is the Material
|
||||
/// equivalent of this class and builds only the Material buttons. It
|
||||
/// includes a live example of using `getAdaptiveButtons`.
|
||||
static Iterable<Widget> getAdaptiveButtons(
|
||||
BuildContext context,
|
||||
List<ContextMenuButtonItem> buttonItems,
|
||||
) {
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.iOS:
|
||||
return buttonItems.map((ContextMenuButtonItem buttonItem) {
|
||||
return CupertinoTextSelectionToolbarButton.buttonItem(
|
||||
buttonItem: buttonItem);
|
||||
});
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.windows:
|
||||
case TargetPlatform.macOS:
|
||||
return buttonItems.map((ContextMenuButtonItem buttonItem) {
|
||||
return CupertinoDesktopTextSelectionToolbarButton.buttonItem(
|
||||
buttonItem: buttonItem);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// If there aren't any buttons to build, build an empty toolbar.
|
||||
if ((children?.isEmpty ?? false) || (buttonItems?.isEmpty ?? false)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final List<Widget> resultChildren =
|
||||
children ?? getAdaptiveButtons(context, buttonItems!).toList();
|
||||
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.fuchsia:
|
||||
return CupertinoTextSelectionToolbar(
|
||||
anchorAbove: anchors.primaryAnchor,
|
||||
anchorBelow: anchors.secondaryAnchor ?? anchors.primaryAnchor,
|
||||
children: resultChildren,
|
||||
);
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.windows:
|
||||
case TargetPlatform.macOS:
|
||||
return CupertinoDesktopTextSelectionToolbar(
|
||||
anchor: anchors.primaryAnchor,
|
||||
children: resultChildren,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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.
|
||||
|
||||
/// @docImport 'package:flutter/material.dart';
|
||||
library;
|
||||
|
||||
import 'package:PiliPlus/common/widgets/text_field/editable_text.dart';
|
||||
import 'package:flutter/cupertino.dart' hide EditableText, EditableTextState;
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart'
|
||||
show SelectionChangedCause, SuggestionSpan;
|
||||
|
||||
/// iOS only shows 3 spell check suggestions in the toolbar.
|
||||
const int _kMaxSuggestions = 3;
|
||||
|
||||
/// The default spell check suggestions toolbar for iOS.
|
||||
///
|
||||
/// Tries to position itself below the [anchors], but if it doesn't fit, then it
|
||||
/// readjusts to fit above bottom view insets.
|
||||
///
|
||||
/// See also:
|
||||
/// * [SpellCheckSuggestionsToolbar], which is similar but for both the
|
||||
/// Material and Cupertino libraries.
|
||||
class CupertinoSpellCheckSuggestionsToolbar extends StatelessWidget {
|
||||
/// Constructs a [CupertinoSpellCheckSuggestionsToolbar].
|
||||
///
|
||||
/// [buttonItems] must not contain more than three items.
|
||||
const CupertinoSpellCheckSuggestionsToolbar({
|
||||
super.key,
|
||||
required this.anchors,
|
||||
required this.buttonItems,
|
||||
}) : assert(buttonItems.length <= _kMaxSuggestions);
|
||||
|
||||
/// Constructs a [CupertinoSpellCheckSuggestionsToolbar] with the default
|
||||
/// children for an [EditableText].
|
||||
///
|
||||
/// See also:
|
||||
/// * [SpellCheckSuggestionsToolbar.editableText], which is similar but
|
||||
/// builds an Android-style toolbar.
|
||||
CupertinoSpellCheckSuggestionsToolbar.editableText({
|
||||
super.key,
|
||||
required EditableTextState editableTextState,
|
||||
}) : buttonItems =
|
||||
buildButtonItems(editableTextState) ?? <ContextMenuButtonItem>[],
|
||||
anchors = editableTextState.contextMenuAnchors;
|
||||
|
||||
/// The location on which to anchor the menu.
|
||||
final TextSelectionToolbarAnchors anchors;
|
||||
|
||||
/// The [ContextMenuButtonItem]s that will be turned into the correct button
|
||||
/// widgets and displayed in the spell check suggestions toolbar.
|
||||
///
|
||||
/// Must not contain more than three items.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [AdaptiveTextSelectionToolbar.buttonItems], the list of
|
||||
/// [ContextMenuButtonItem]s that are used to build the buttons of the
|
||||
/// text selection toolbar.
|
||||
/// * [SpellCheckSuggestionsToolbar.buttonItems], the list of
|
||||
/// [ContextMenuButtonItem]s used to build the Material style spell check
|
||||
/// suggestions toolbar.
|
||||
final List<ContextMenuButtonItem> buttonItems;
|
||||
|
||||
/// Builds the button items for the toolbar based on the available
|
||||
/// spell check suggestions.
|
||||
static List<ContextMenuButtonItem>? buildButtonItems(
|
||||
EditableTextState editableTextState) {
|
||||
// Determine if composing region is misspelled.
|
||||
final SuggestionSpan? spanAtCursorIndex =
|
||||
editableTextState.findSuggestionSpanAtCursorIndex(
|
||||
editableTextState.currentTextEditingValue.selection.baseOffset,
|
||||
);
|
||||
|
||||
if (spanAtCursorIndex == null) {
|
||||
return null;
|
||||
}
|
||||
if (spanAtCursorIndex.suggestions.isEmpty) {
|
||||
assert(debugCheckHasCupertinoLocalizations(editableTextState.context));
|
||||
final CupertinoLocalizations localizations = CupertinoLocalizations.of(
|
||||
editableTextState.context,
|
||||
);
|
||||
return <ContextMenuButtonItem>[
|
||||
ContextMenuButtonItem(
|
||||
onPressed: null,
|
||||
label: localizations.noSpellCheckReplacementsLabel),
|
||||
];
|
||||
}
|
||||
|
||||
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
|
||||
|
||||
// Build suggestion buttons.
|
||||
for (final String suggestion
|
||||
in spanAtCursorIndex.suggestions.take(_kMaxSuggestions)) {
|
||||
buttonItems.add(
|
||||
ContextMenuButtonItem(
|
||||
onPressed: () {
|
||||
if (!editableTextState.mounted) {
|
||||
return;
|
||||
}
|
||||
_replaceText(
|
||||
editableTextState, suggestion, spanAtCursorIndex.range);
|
||||
},
|
||||
label: suggestion,
|
||||
),
|
||||
);
|
||||
}
|
||||
return buttonItems;
|
||||
}
|
||||
|
||||
static void _replaceText(
|
||||
EditableTextState editableTextState,
|
||||
String text,
|
||||
TextRange replacementRange,
|
||||
) {
|
||||
// Replacement cannot be performed if the text is read only or obscured.
|
||||
assert(!editableTextState.widget.readOnly &&
|
||||
!editableTextState.widget.obscureText);
|
||||
|
||||
final TextEditingValue newValue = editableTextState.textEditingValue
|
||||
.replaced(replacementRange, text)
|
||||
.copyWith(
|
||||
selection: TextSelection.collapsed(
|
||||
offset: replacementRange.start + text.length));
|
||||
editableTextState.userUpdateTextEditingValue(
|
||||
newValue, SelectionChangedCause.toolbar);
|
||||
|
||||
// Schedule a call to bringIntoView() after renderEditable updates.
|
||||
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
|
||||
if (editableTextState.mounted) {
|
||||
editableTextState
|
||||
.bringIntoView(editableTextState.textEditingValue.selection.extent);
|
||||
}
|
||||
}, debugLabel: 'SpellCheckSuggestions.bringIntoView');
|
||||
editableTextState.hideToolbar();
|
||||
}
|
||||
|
||||
/// Builds the toolbar buttons based on the [buttonItems].
|
||||
List<Widget> _buildToolbarButtons(BuildContext context) {
|
||||
return buttonItems.map((ContextMenuButtonItem buttonItem) {
|
||||
return CupertinoTextSelectionToolbarButton.buttonItem(
|
||||
buttonItem: buttonItem);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (buttonItems.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final List<Widget> children = _buildToolbarButtons(context);
|
||||
return CupertinoTextSelectionToolbar(
|
||||
anchorAbove: anchors.primaryAnchor,
|
||||
anchorBelow: anchors.secondaryAnchor == null
|
||||
? anchors.primaryAnchor
|
||||
: anchors.secondaryAnchor!,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
1759
lib/common/widgets/text_field/cupertino/cupertino_text_field.dart
Normal file
1759
lib/common/widgets/text_field/cupertino/cupertino_text_field.dart
Normal file
File diff suppressed because it is too large
Load Diff
6609
lib/common/widgets/text_field/editable_text.dart
Normal file
6609
lib/common/widgets/text_field/editable_text.dart
Normal file
File diff suppressed because it is too large
Load Diff
461
lib/common/widgets/text_field/spell_check.dart
Normal file
461
lib/common/widgets/text_field/spell_check.dart
Normal file
@@ -0,0 +1,461 @@
|
||||
// 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.
|
||||
|
||||
/// @docImport 'editable_text.dart';
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter/services.dart'
|
||||
show SpellCheckResults, SpellCheckService, SuggestionSpan, TextEditingValue;
|
||||
|
||||
import 'editable_text.dart' show EditableTextContextMenuBuilder;
|
||||
|
||||
/// Controls how spell check is performed for text input.
|
||||
///
|
||||
/// This configuration determines the [SpellCheckService] used to fetch the
|
||||
/// [List<SuggestionSpan>] spell check results and the [TextStyle] used to
|
||||
/// mark misspelled words within text input.
|
||||
@immutable
|
||||
class SpellCheckConfiguration {
|
||||
/// Creates a configuration that specifies the service and suggestions handler
|
||||
/// for spell check.
|
||||
const SpellCheckConfiguration({
|
||||
this.spellCheckService,
|
||||
this.misspelledSelectionColor,
|
||||
this.misspelledTextStyle,
|
||||
this.spellCheckSuggestionsToolbarBuilder,
|
||||
}) : _spellCheckEnabled = true;
|
||||
|
||||
/// Creates a configuration that disables spell check.
|
||||
const SpellCheckConfiguration.disabled()
|
||||
: _spellCheckEnabled = false,
|
||||
spellCheckService = null,
|
||||
spellCheckSuggestionsToolbarBuilder = null,
|
||||
misspelledTextStyle = null,
|
||||
misspelledSelectionColor = null;
|
||||
|
||||
/// The service used to fetch spell check results for text input.
|
||||
final SpellCheckService? spellCheckService;
|
||||
|
||||
/// The color the paint the selection highlight when spell check is showing
|
||||
/// suggestions for a misspelled word.
|
||||
///
|
||||
/// For example, on iOS, the selection appears red while the spell check menu
|
||||
/// is showing.
|
||||
final Color? misspelledSelectionColor;
|
||||
|
||||
/// Style used to indicate misspelled words.
|
||||
///
|
||||
/// This is nullable to allow style-specific wrappers of [EditableText]
|
||||
/// to infer this, but this must be specified if this configuration is
|
||||
/// provided directly to [EditableText] or its construction will fail with an
|
||||
/// assertion error.
|
||||
final TextStyle? misspelledTextStyle;
|
||||
|
||||
/// Builds the toolbar used to display spell check suggestions for misspelled
|
||||
/// words.
|
||||
final EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder;
|
||||
|
||||
final bool _spellCheckEnabled;
|
||||
|
||||
/// Whether or not the configuration should enable or disable spell check.
|
||||
bool get spellCheckEnabled => _spellCheckEnabled;
|
||||
|
||||
/// Returns a copy of the current [SpellCheckConfiguration] instance with
|
||||
/// specified overrides.
|
||||
SpellCheckConfiguration copyWith({
|
||||
SpellCheckService? spellCheckService,
|
||||
Color? misspelledSelectionColor,
|
||||
TextStyle? misspelledTextStyle,
|
||||
EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder,
|
||||
}) {
|
||||
if (!_spellCheckEnabled) {
|
||||
// A new configuration should be constructed to enable spell check.
|
||||
return const SpellCheckConfiguration.disabled();
|
||||
}
|
||||
|
||||
return SpellCheckConfiguration(
|
||||
spellCheckService: spellCheckService ?? this.spellCheckService,
|
||||
misspelledSelectionColor:
|
||||
misspelledSelectionColor ?? this.misspelledSelectionColor,
|
||||
misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle,
|
||||
spellCheckSuggestionsToolbarBuilder:
|
||||
spellCheckSuggestionsToolbarBuilder ??
|
||||
this.spellCheckSuggestionsToolbarBuilder,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '${objectRuntimeType(this, 'SpellCheckConfiguration')}('
|
||||
'${_spellCheckEnabled ? 'enabled' : 'disabled'}, '
|
||||
'service: $spellCheckService, '
|
||||
'text style: $misspelledTextStyle, '
|
||||
'toolbar builder: $spellCheckSuggestionsToolbarBuilder'
|
||||
')';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
return other is SpellCheckConfiguration &&
|
||||
other.spellCheckService == spellCheckService &&
|
||||
other.misspelledTextStyle == misspelledTextStyle &&
|
||||
other.spellCheckSuggestionsToolbarBuilder ==
|
||||
spellCheckSuggestionsToolbarBuilder &&
|
||||
other._spellCheckEnabled == _spellCheckEnabled;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
spellCheckService,
|
||||
misspelledTextStyle,
|
||||
spellCheckSuggestionsToolbarBuilder,
|
||||
_spellCheckEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
// Methods for displaying spell check results:
|
||||
|
||||
/// Adjusts spell check results to correspond to [newText] if the only results
|
||||
/// that the handler has access to are the [results] corresponding to
|
||||
/// [resultsText].
|
||||
///
|
||||
/// Used in the case where the request for the spell check results of the
|
||||
/// [newText] is lagging in order to avoid display of incorrect results.
|
||||
List<SuggestionSpan> _correctSpellCheckResults(
|
||||
String newText,
|
||||
String resultsText,
|
||||
List<SuggestionSpan> results,
|
||||
) {
|
||||
final List<SuggestionSpan> correctedSpellCheckResults = <SuggestionSpan>[];
|
||||
int spanPointer = 0;
|
||||
int offset = 0;
|
||||
|
||||
// Assumes that the order of spans has not been jumbled for optimization
|
||||
// purposes, and will only search since the previously found span.
|
||||
int searchStart = 0;
|
||||
|
||||
while (spanPointer < results.length) {
|
||||
final SuggestionSpan currentSpan = results[spanPointer];
|
||||
final String currentSpanText = resultsText.substring(
|
||||
currentSpan.range.start,
|
||||
currentSpan.range.end,
|
||||
);
|
||||
final int spanLength = currentSpan.range.end - currentSpan.range.start;
|
||||
|
||||
// Try finding SuggestionSpan from resultsText in new text.
|
||||
final String escapedText = RegExp.escape(currentSpanText);
|
||||
final RegExp currentSpanTextRegexp = RegExp('\\b$escapedText\\b');
|
||||
final int foundIndex =
|
||||
newText.substring(searchStart).indexOf(currentSpanTextRegexp);
|
||||
|
||||
// Check whether word was found exactly where expected or elsewhere in the newText.
|
||||
final bool currentSpanFoundExactly =
|
||||
currentSpan.range.start == foundIndex + searchStart;
|
||||
final bool currentSpanFoundExactlyWithOffset =
|
||||
currentSpan.range.start + offset == foundIndex + searchStart;
|
||||
final bool currentSpanFoundElsewhere = foundIndex >= 0;
|
||||
|
||||
if (currentSpanFoundExactly || currentSpanFoundExactlyWithOffset) {
|
||||
// currentSpan was found at the same index in newText and resultsText
|
||||
// or at the same index with the previously calculated adjustment by
|
||||
// the offset value, so apply it to new text by adding it to the list of
|
||||
// corrected results.
|
||||
final SuggestionSpan adjustedSpan = SuggestionSpan(
|
||||
TextRange(
|
||||
start: currentSpan.range.start + offset,
|
||||
end: currentSpan.range.end + offset),
|
||||
currentSpan.suggestions,
|
||||
);
|
||||
|
||||
// Start search for the next misspelled word at the end of currentSpan.
|
||||
searchStart =
|
||||
math.min(currentSpan.range.end + 1 + offset, newText.length);
|
||||
correctedSpellCheckResults.add(adjustedSpan);
|
||||
} else if (currentSpanFoundElsewhere) {
|
||||
// Word was pushed forward but not modified.
|
||||
final int adjustedSpanStart = searchStart + foundIndex;
|
||||
final int adjustedSpanEnd = adjustedSpanStart + spanLength;
|
||||
final SuggestionSpan adjustedSpan = SuggestionSpan(
|
||||
TextRange(start: adjustedSpanStart, end: adjustedSpanEnd),
|
||||
currentSpan.suggestions,
|
||||
);
|
||||
|
||||
// Start search for the next misspelled word at the end of the
|
||||
// adjusted currentSpan.
|
||||
searchStart = math.min(adjustedSpanEnd + 1, newText.length);
|
||||
// Adjust offset to reflect the difference between where currentSpan
|
||||
// was positioned in resultsText versus in newText.
|
||||
offset = adjustedSpanStart - currentSpan.range.start;
|
||||
correctedSpellCheckResults.add(adjustedSpan);
|
||||
}
|
||||
spanPointer++;
|
||||
}
|
||||
return correctedSpellCheckResults;
|
||||
}
|
||||
|
||||
/// Builds the [TextSpan] tree given the current state of the text input and
|
||||
/// spell check results.
|
||||
///
|
||||
/// The [value] is the current [TextEditingValue] requested to be rendered
|
||||
/// by a text input widget. The [composingWithinCurrentTextRange] value
|
||||
/// represents whether or not there is a valid composing region in the
|
||||
/// [value]. The [style] is the [TextStyle] to render the [value]'s text with,
|
||||
/// and the [misspelledTextStyle] is the [TextStyle] to render misspelled
|
||||
/// words within the [value]'s text with. The [spellCheckResults] are the
|
||||
/// results of spell checking the [value]'s text.
|
||||
TextSpan buildTextSpanWithSpellCheckSuggestions(
|
||||
TextEditingValue value,
|
||||
bool composingWithinCurrentTextRange,
|
||||
TextStyle? style,
|
||||
TextStyle misspelledTextStyle,
|
||||
SpellCheckResults spellCheckResults,
|
||||
) {
|
||||
List<SuggestionSpan> spellCheckResultsSpans =
|
||||
spellCheckResults.suggestionSpans;
|
||||
final String spellCheckResultsText = spellCheckResults.spellCheckedText;
|
||||
|
||||
if (spellCheckResultsText != value.text) {
|
||||
spellCheckResultsSpans = _correctSpellCheckResults(
|
||||
value.text,
|
||||
spellCheckResultsText,
|
||||
spellCheckResultsSpans,
|
||||
);
|
||||
}
|
||||
|
||||
// We will draw the TextSpan tree based on the composing region, if it is
|
||||
// available.
|
||||
// TODO(camsim99): The two separate strategies for building TextSpan trees
|
||||
// based on the availability of a composing region should be merged:
|
||||
// https://github.com/flutter/flutter/issues/124142.
|
||||
final bool shouldConsiderComposingRegion =
|
||||
defaultTargetPlatform == TargetPlatform.android;
|
||||
if (shouldConsiderComposingRegion) {
|
||||
return TextSpan(
|
||||
style: style,
|
||||
children: _buildSubtreesWithComposingRegion(
|
||||
spellCheckResultsSpans,
|
||||
value,
|
||||
style,
|
||||
misspelledTextStyle,
|
||||
composingWithinCurrentTextRange,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return TextSpan(
|
||||
style: style,
|
||||
children: _buildSubtreesWithoutComposingRegion(
|
||||
spellCheckResultsSpans,
|
||||
value,
|
||||
style,
|
||||
misspelledTextStyle,
|
||||
value.selection.baseOffset,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the [TextSpan] tree for spell check without considering the composing
|
||||
/// region. Instead, uses the cursor to identify the word that's actively being
|
||||
/// edited and shouldn't be spell checked. This is useful for platforms and IMEs
|
||||
/// that don't use the composing region for the active word.
|
||||
List<TextSpan> _buildSubtreesWithoutComposingRegion(
|
||||
List<SuggestionSpan>? spellCheckSuggestions,
|
||||
TextEditingValue value,
|
||||
TextStyle? style,
|
||||
TextStyle misspelledStyle,
|
||||
int cursorIndex,
|
||||
) {
|
||||
final List<TextSpan> textSpanTreeChildren = <TextSpan>[];
|
||||
|
||||
int textPointer = 0;
|
||||
int currentSpanPointer = 0;
|
||||
int endIndex;
|
||||
final String text = value.text;
|
||||
final TextStyle misspelledJointStyle =
|
||||
style?.merge(misspelledStyle) ?? misspelledStyle;
|
||||
bool cursorInCurrentSpan = false;
|
||||
|
||||
// Add text interwoven with any misspelled words to the tree.
|
||||
if (spellCheckSuggestions != null) {
|
||||
while (textPointer < text.length &&
|
||||
currentSpanPointer < spellCheckSuggestions.length) {
|
||||
final SuggestionSpan currentSpan =
|
||||
spellCheckSuggestions[currentSpanPointer];
|
||||
|
||||
if (currentSpan.range.start > textPointer) {
|
||||
endIndex = currentSpan.range.start < text.length
|
||||
? currentSpan.range.start
|
||||
: text.length;
|
||||
textSpanTreeChildren.add(
|
||||
TextSpan(style: style, text: text.substring(textPointer, endIndex)),
|
||||
);
|
||||
textPointer = endIndex;
|
||||
} else {
|
||||
endIndex = currentSpan.range.end < text.length
|
||||
? currentSpan.range.end
|
||||
: text.length;
|
||||
cursorInCurrentSpan = currentSpan.range.start <= cursorIndex &&
|
||||
currentSpan.range.end >= cursorIndex;
|
||||
textSpanTreeChildren.add(
|
||||
TextSpan(
|
||||
style: cursorInCurrentSpan ? style : misspelledJointStyle,
|
||||
text: text.substring(currentSpan.range.start, endIndex),
|
||||
),
|
||||
);
|
||||
|
||||
textPointer = endIndex;
|
||||
currentSpanPointer++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining text to the tree if applicable.
|
||||
if (textPointer < text.length) {
|
||||
textSpanTreeChildren.add(
|
||||
TextSpan(style: style, text: text.substring(textPointer, text.length)),
|
||||
);
|
||||
}
|
||||
|
||||
return textSpanTreeChildren;
|
||||
}
|
||||
|
||||
/// Builds [TextSpan] subtree for text with misspelled words with logic based on
|
||||
/// a valid composing region.
|
||||
List<TextSpan> _buildSubtreesWithComposingRegion(
|
||||
List<SuggestionSpan>? spellCheckSuggestions,
|
||||
TextEditingValue value,
|
||||
TextStyle? style,
|
||||
TextStyle misspelledStyle,
|
||||
bool composingWithinCurrentTextRange,
|
||||
) {
|
||||
final List<TextSpan> textSpanTreeChildren = <TextSpan>[];
|
||||
|
||||
int textPointer = 0;
|
||||
int currentSpanPointer = 0;
|
||||
int endIndex;
|
||||
SuggestionSpan currentSpan;
|
||||
final String text = value.text;
|
||||
final TextRange composingRegion = value.composing;
|
||||
final TextStyle composingTextStyle =
|
||||
style?.merge(const TextStyle(decoration: TextDecoration.underline)) ??
|
||||
const TextStyle(decoration: TextDecoration.underline);
|
||||
final TextStyle misspelledJointStyle =
|
||||
style?.merge(misspelledStyle) ?? misspelledStyle;
|
||||
bool textPointerWithinComposingRegion = false;
|
||||
bool currentSpanIsComposingRegion = false;
|
||||
|
||||
// Add text interwoven with any misspelled words to the tree.
|
||||
if (spellCheckSuggestions != null) {
|
||||
while (textPointer < text.length &&
|
||||
currentSpanPointer < spellCheckSuggestions.length) {
|
||||
currentSpan = spellCheckSuggestions[currentSpanPointer];
|
||||
|
||||
if (currentSpan.range.start > textPointer) {
|
||||
endIndex = currentSpan.range.start < text.length
|
||||
? currentSpan.range.start
|
||||
: text.length;
|
||||
textPointerWithinComposingRegion =
|
||||
composingRegion.start >= textPointer &&
|
||||
composingRegion.end <= endIndex &&
|
||||
!composingWithinCurrentTextRange;
|
||||
|
||||
if (textPointerWithinComposingRegion) {
|
||||
_addComposingRegionTextSpans(
|
||||
textSpanTreeChildren,
|
||||
text,
|
||||
textPointer,
|
||||
composingRegion,
|
||||
style,
|
||||
composingTextStyle,
|
||||
);
|
||||
textSpanTreeChildren.add(
|
||||
TextSpan(
|
||||
style: style,
|
||||
text: text.substring(composingRegion.end, endIndex)),
|
||||
);
|
||||
} else {
|
||||
textSpanTreeChildren.add(
|
||||
TextSpan(style: style, text: text.substring(textPointer, endIndex)),
|
||||
);
|
||||
}
|
||||
|
||||
textPointer = endIndex;
|
||||
} else {
|
||||
endIndex = currentSpan.range.end < text.length
|
||||
? currentSpan.range.end
|
||||
: text.length;
|
||||
currentSpanIsComposingRegion = textPointer >= composingRegion.start &&
|
||||
endIndex <= composingRegion.end &&
|
||||
!composingWithinCurrentTextRange;
|
||||
textSpanTreeChildren.add(
|
||||
TextSpan(
|
||||
style: currentSpanIsComposingRegion
|
||||
? composingTextStyle
|
||||
: misspelledJointStyle,
|
||||
text: text.substring(currentSpan.range.start, endIndex),
|
||||
),
|
||||
);
|
||||
|
||||
textPointer = endIndex;
|
||||
currentSpanPointer++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining text to the tree if applicable.
|
||||
if (textPointer < text.length) {
|
||||
if (textPointer < composingRegion.start &&
|
||||
!composingWithinCurrentTextRange) {
|
||||
_addComposingRegionTextSpans(
|
||||
textSpanTreeChildren,
|
||||
text,
|
||||
textPointer,
|
||||
composingRegion,
|
||||
style,
|
||||
composingTextStyle,
|
||||
);
|
||||
|
||||
if (composingRegion.end != text.length) {
|
||||
textSpanTreeChildren.add(
|
||||
TextSpan(
|
||||
style: style,
|
||||
text: text.substring(composingRegion.end, text.length)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
textSpanTreeChildren.add(
|
||||
TextSpan(style: style, text: text.substring(textPointer, text.length)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return textSpanTreeChildren;
|
||||
}
|
||||
|
||||
/// Helper method to create [TextSpan] tree children for specified range of
|
||||
/// text up to and including the composing region.
|
||||
void _addComposingRegionTextSpans(
|
||||
List<TextSpan> treeChildren,
|
||||
String text,
|
||||
int start,
|
||||
TextRange composingRegion,
|
||||
TextStyle? style,
|
||||
TextStyle composingTextStyle,
|
||||
) {
|
||||
treeChildren.add(TextSpan(
|
||||
style: style, text: text.substring(start, composingRegion.start)));
|
||||
treeChildren.add(
|
||||
TextSpan(
|
||||
style: composingTextStyle,
|
||||
text: text.substring(composingRegion.start, composingRegion.end),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// 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:PiliPlus/common/widgets/text_field/editable_text.dart';
|
||||
import 'package:flutter/cupertino.dart' hide EditableText, EditableTextState;
|
||||
import 'package:flutter/material.dart' hide EditableText, EditableTextState;
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart'
|
||||
show SelectionChangedCause, SuggestionSpan;
|
||||
|
||||
// The default height of the SpellCheckSuggestionsToolbar, which
|
||||
// assumes there are the maximum number of spell check suggestions available, 3.
|
||||
// Size eyeballed on Pixel 4 emulator running Android API 31.
|
||||
const double _kDefaultToolbarHeight = 193.0;
|
||||
|
||||
/// The maximum number of suggestions in the toolbar is 3, plus a delete button.
|
||||
const int _kMaxSuggestions = 3;
|
||||
|
||||
/// The default spell check suggestions toolbar for Android.
|
||||
///
|
||||
/// Tries to position itself below the [anchor], but if it doesn't fit, then it
|
||||
/// readjusts to fit above bottom view insets.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [CupertinoSpellCheckSuggestionsToolbar], which is similar but builds an
|
||||
/// iOS-style spell check toolbar.
|
||||
class SpellCheckSuggestionsToolbar extends StatelessWidget {
|
||||
/// Constructs a [SpellCheckSuggestionsToolbar].
|
||||
///
|
||||
/// [buttonItems] must not contain more than four items, generally three
|
||||
/// suggestions and one delete button.
|
||||
const SpellCheckSuggestionsToolbar(
|
||||
{super.key, required this.anchor, required this.buttonItems})
|
||||
: assert(buttonItems.length <= _kMaxSuggestions + 1);
|
||||
|
||||
/// Constructs a [SpellCheckSuggestionsToolbar] with the default children for
|
||||
/// an [EditableText].
|
||||
///
|
||||
/// See also:
|
||||
/// * [CupertinoSpellCheckSuggestionsToolbar.editableText], which is similar
|
||||
/// but builds an iOS-style toolbar.
|
||||
SpellCheckSuggestionsToolbar.editableText({
|
||||
super.key,
|
||||
required EditableTextState editableTextState,
|
||||
}) : buttonItems =
|
||||
buildButtonItems(editableTextState) ?? <ContextMenuButtonItem>[],
|
||||
anchor = getToolbarAnchor(editableTextState.contextMenuAnchors);
|
||||
|
||||
/// {@template flutter.material.SpellCheckSuggestionsToolbar.anchor}
|
||||
/// The focal point below which the toolbar attempts to position itself.
|
||||
/// {@endtemplate}
|
||||
final Offset anchor;
|
||||
|
||||
/// The [ContextMenuButtonItem]s that will be turned into the correct button
|
||||
/// widgets and displayed in the spell check suggestions toolbar.
|
||||
///
|
||||
/// Must not contain more than four items, typically three suggestions and a
|
||||
/// delete button.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [AdaptiveTextSelectionToolbar.buttonItems], the list of
|
||||
/// [ContextMenuButtonItem]s that are used to build the buttons of the
|
||||
/// text selection toolbar.
|
||||
/// * [CupertinoSpellCheckSuggestionsToolbar.buttonItems], the list of
|
||||
/// [ContextMenuButtonItem]s used to build the Cupertino style spell check
|
||||
/// suggestions toolbar.
|
||||
final List<ContextMenuButtonItem> buttonItems;
|
||||
|
||||
/// Builds the button items for the toolbar based on the available
|
||||
/// spell check suggestions.
|
||||
static List<ContextMenuButtonItem>? buildButtonItems(
|
||||
EditableTextState editableTextState) {
|
||||
// Determine if composing region is misspelled.
|
||||
final SuggestionSpan? spanAtCursorIndex =
|
||||
editableTextState.findSuggestionSpanAtCursorIndex(
|
||||
editableTextState.currentTextEditingValue.selection.baseOffset,
|
||||
);
|
||||
|
||||
if (spanAtCursorIndex == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
|
||||
|
||||
// Build suggestion buttons.
|
||||
for (final String suggestion
|
||||
in spanAtCursorIndex.suggestions.take(_kMaxSuggestions)) {
|
||||
buttonItems.add(
|
||||
ContextMenuButtonItem(
|
||||
onPressed: () {
|
||||
if (!editableTextState.mounted) {
|
||||
return;
|
||||
}
|
||||
_replaceText(
|
||||
editableTextState, suggestion, spanAtCursorIndex.range);
|
||||
},
|
||||
label: suggestion,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Build delete button.
|
||||
final ContextMenuButtonItem deleteButton = ContextMenuButtonItem(
|
||||
onPressed: () {
|
||||
if (!editableTextState.mounted) {
|
||||
return;
|
||||
}
|
||||
_replaceText(editableTextState, '',
|
||||
editableTextState.currentTextEditingValue.composing);
|
||||
},
|
||||
type: ContextMenuButtonType.delete,
|
||||
);
|
||||
buttonItems.add(deleteButton);
|
||||
|
||||
return buttonItems;
|
||||
}
|
||||
|
||||
static void _replaceText(
|
||||
EditableTextState editableTextState,
|
||||
String text,
|
||||
TextRange replacementRange,
|
||||
) {
|
||||
// Replacement cannot be performed if the text is read only or obscured.
|
||||
assert(!editableTextState.widget.readOnly &&
|
||||
!editableTextState.widget.obscureText);
|
||||
|
||||
final TextEditingValue newValue =
|
||||
editableTextState.textEditingValue.replaced(
|
||||
replacementRange,
|
||||
text,
|
||||
);
|
||||
editableTextState.userUpdateTextEditingValue(
|
||||
newValue, SelectionChangedCause.toolbar);
|
||||
|
||||
// Schedule a call to bringIntoView() after renderEditable updates.
|
||||
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
|
||||
if (editableTextState.mounted) {
|
||||
editableTextState
|
||||
.bringIntoView(editableTextState.textEditingValue.selection.extent);
|
||||
}
|
||||
}, debugLabel: 'SpellCheckerSuggestionsToolbar.bringIntoView');
|
||||
editableTextState.hideToolbar();
|
||||
}
|
||||
|
||||
/// Determines the Offset that the toolbar will be anchored to.
|
||||
static Offset getToolbarAnchor(TextSelectionToolbarAnchors anchors) {
|
||||
// Since this will be positioned below the anchor point, use the secondary
|
||||
// anchor by default.
|
||||
return anchors.secondaryAnchor == null
|
||||
? anchors.primaryAnchor
|
||||
: anchors.secondaryAnchor!;
|
||||
}
|
||||
|
||||
/// Builds the toolbar buttons based on the [buttonItems].
|
||||
List<Widget> _buildToolbarButtons(BuildContext context) {
|
||||
return buttonItems.map((ContextMenuButtonItem buttonItem) {
|
||||
final TextSelectionToolbarTextButton button =
|
||||
TextSelectionToolbarTextButton(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 0, 0),
|
||||
onPressed: buttonItem.onPressed,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
AdaptiveTextSelectionToolbar.getButtonLabel(context, buttonItem),
|
||||
style: buttonItem.type == ContextMenuButtonType.delete
|
||||
? const TextStyle(color: Colors.blue)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
if (buttonItem.type != ContextMenuButtonType.delete) {
|
||||
return button;
|
||||
}
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(top: BorderSide(color: Colors.grey))),
|
||||
child: button,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (buttonItems.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Adjust toolbar height if needed.
|
||||
final double spellCheckSuggestionsToolbarHeight =
|
||||
_kDefaultToolbarHeight - (48.0 * (4 - buttonItems.length));
|
||||
// Incorporate the padding distance between the content and toolbar.
|
||||
final MediaQueryData mediaQueryData = MediaQuery.of(context);
|
||||
final double softKeyboardViewInsetsBottom =
|
||||
mediaQueryData.viewInsets.bottom;
|
||||
final double paddingAbove = mediaQueryData.padding.top +
|
||||
CupertinoTextSelectionToolbar.kToolbarScreenPadding;
|
||||
// Makes up for the Padding.
|
||||
final Offset localAdjustment = Offset(
|
||||
CupertinoTextSelectionToolbar.kToolbarScreenPadding,
|
||||
paddingAbove,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
CupertinoTextSelectionToolbar.kToolbarScreenPadding,
|
||||
paddingAbove,
|
||||
CupertinoTextSelectionToolbar.kToolbarScreenPadding,
|
||||
CupertinoTextSelectionToolbar.kToolbarScreenPadding +
|
||||
softKeyboardViewInsetsBottom,
|
||||
),
|
||||
child: CustomSingleChildLayout(
|
||||
delegate: SpellCheckSuggestionsToolbarLayoutDelegate(
|
||||
anchor: anchor - localAdjustment),
|
||||
child: AnimatedSize(
|
||||
// This duration was eyeballed on a Pixel 2 emulator running Android
|
||||
// API 28 for the Material TextSelectionToolbar.
|
||||
duration: const Duration(milliseconds: 140),
|
||||
child: _SpellCheckSuggestionsToolbarContainer(
|
||||
height: spellCheckSuggestionsToolbarHeight,
|
||||
children: <Widget>[..._buildToolbarButtons(context)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Material-styled toolbar outline for the spell check suggestions
|
||||
/// toolbar.
|
||||
class _SpellCheckSuggestionsToolbarContainer extends StatelessWidget {
|
||||
const _SpellCheckSuggestionsToolbarContainer(
|
||||
{required this.height, required this.children});
|
||||
|
||||
final double height;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
// This elevation was eyeballed on a Pixel 4 emulator running Android
|
||||
// API 31 for the SpellCheckSuggestionsToolbar.
|
||||
elevation: 2.0,
|
||||
type: MaterialType.card,
|
||||
child: SizedBox(
|
||||
// This width was eyeballed on a Pixel 4 emulator running Android
|
||||
// API 31 for the SpellCheckSuggestionsToolbar.
|
||||
width: 165.0,
|
||||
height: height,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
424
lib/common/widgets/text_field/system_context_menu.dart
Normal file
424
lib/common/widgets/text_field/system_context_menu.dart
Normal file
@@ -0,0 +1,424 @@
|
||||
// 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.
|
||||
|
||||
/// @docImport 'package:flutter/material.dart';
|
||||
library;
|
||||
|
||||
import 'package:PiliPlus/common/widgets/text_field/editable_text.dart';
|
||||
import 'package:flutter/material.dart' hide EditableText, EditableTextState;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Displays the system context menu on top of the Flutter view.
|
||||
///
|
||||
/// Currently, only supports iOS 16.0 and above and displays nothing on other
|
||||
/// platforms.
|
||||
///
|
||||
/// The context menu is the menu that appears, for example, when doing text
|
||||
/// selection. Flutter typically draws this menu itself, but this class deals
|
||||
/// with the platform-rendered context menu instead.
|
||||
///
|
||||
/// There can only be one system context menu visible at a time. Building this
|
||||
/// widget when the system context menu is already visible will hide the old one
|
||||
/// and display this one. A system context menu that is hidden is informed via
|
||||
/// [onSystemHide].
|
||||
///
|
||||
/// Pass [items] to specify the buttons that will appear in the menu. Any items
|
||||
/// without a title will be given a default title from [WidgetsLocalizations].
|
||||
///
|
||||
/// By default, [items] will be set to the result of [getDefaultItems]. This
|
||||
/// method considers the state of the [EditableTextState] so that, for example,
|
||||
/// it will only include [IOSSystemContextMenuItemCopy] if there is currently a
|
||||
/// selection to copy.
|
||||
///
|
||||
/// To check if the current device supports showing the system context menu,
|
||||
/// call [isSupported].
|
||||
///
|
||||
/// {@tool dartpad}
|
||||
/// This example shows how to create a [TextField] that uses the system context
|
||||
/// menu where supported and does not show a system notification when the user
|
||||
/// presses the "Paste" button.
|
||||
///
|
||||
/// ** See code in examples/api/lib/widgets/system_context_menu/system_context_menu.0.dart **
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [SystemContextMenuController], which directly controls the hiding and
|
||||
/// showing of the system context menu.
|
||||
class SystemContextMenu extends StatefulWidget {
|
||||
/// Creates an instance of [SystemContextMenu] that points to the given
|
||||
/// [anchor].
|
||||
const SystemContextMenu._({
|
||||
super.key,
|
||||
required this.anchor,
|
||||
required this.items,
|
||||
this.onSystemHide,
|
||||
});
|
||||
|
||||
/// Creates an instance of [SystemContextMenu] for the field indicated by the
|
||||
/// given [EditableTextState].
|
||||
factory SystemContextMenu.editableText({
|
||||
Key? key,
|
||||
required EditableTextState editableTextState,
|
||||
List<IOSSystemContextMenuItem>? items,
|
||||
}) {
|
||||
final (
|
||||
startGlyphHeight: double startGlyphHeight,
|
||||
endGlyphHeight: double endGlyphHeight
|
||||
) = editableTextState.getGlyphHeights();
|
||||
|
||||
return SystemContextMenu._(
|
||||
key: key,
|
||||
anchor: TextSelectionToolbarAnchors.getSelectionRect(
|
||||
editableTextState.renderEditable,
|
||||
startGlyphHeight,
|
||||
endGlyphHeight,
|
||||
editableTextState.renderEditable.getEndpointsForSelection(
|
||||
editableTextState.textEditingValue.selection,
|
||||
),
|
||||
),
|
||||
items: items ?? getDefaultItems(editableTextState),
|
||||
onSystemHide: editableTextState.hideToolbar,
|
||||
);
|
||||
}
|
||||
|
||||
/// The [Rect] that the context menu should point to.
|
||||
final Rect anchor;
|
||||
|
||||
/// A list of the items to be displayed in the system context menu.
|
||||
///
|
||||
/// When passed, items will be shown regardless of the state of text input.
|
||||
/// For example, [IOSSystemContextMenuItemCopy] will produce a copy button
|
||||
/// even when there is no selection to copy. Use [EditableTextState] and/or
|
||||
/// the result of [getDefaultItems] to add and remove items based on the state
|
||||
/// of the input.
|
||||
///
|
||||
/// Defaults to the result of [getDefaultItems].
|
||||
final List<IOSSystemContextMenuItem> items;
|
||||
|
||||
/// Called when the system hides this context menu.
|
||||
///
|
||||
/// For example, tapping outside of the context menu typically causes the
|
||||
/// system to hide the menu.
|
||||
///
|
||||
/// This is not called when showing a new system context menu causes another
|
||||
/// to be hidden.
|
||||
final VoidCallback? onSystemHide;
|
||||
|
||||
/// Whether the current device supports showing the system context menu.
|
||||
///
|
||||
/// Currently, this is only supported on newer versions of iOS.
|
||||
static bool isSupported(BuildContext context) {
|
||||
return MediaQuery.maybeSupportsShowingSystemContextMenu(context) ?? false;
|
||||
}
|
||||
|
||||
/// The default [items] for the given [EditableTextState].
|
||||
///
|
||||
/// For example, [IOSSystemContextMenuItemCopy] will only be included when the
|
||||
/// field represented by the [EditableTextState] has a selection.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [EditableTextState.contextMenuButtonItems], which provides the default
|
||||
/// [ContextMenuButtonItem]s for the Flutter-rendered context menu.
|
||||
static List<IOSSystemContextMenuItem> getDefaultItems(
|
||||
EditableTextState editableTextState) {
|
||||
return <IOSSystemContextMenuItem>[
|
||||
if (editableTextState.copyEnabled) const IOSSystemContextMenuItemCopy(),
|
||||
if (editableTextState.cutEnabled) const IOSSystemContextMenuItemCut(),
|
||||
if (editableTextState.pasteEnabled) const IOSSystemContextMenuItemPaste(),
|
||||
if (editableTextState.selectAllEnabled)
|
||||
const IOSSystemContextMenuItemSelectAll(),
|
||||
if (editableTextState.lookUpEnabled)
|
||||
const IOSSystemContextMenuItemLookUp(),
|
||||
if (editableTextState.searchWebEnabled)
|
||||
const IOSSystemContextMenuItemSearchWeb(),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
State<SystemContextMenu> createState() => _SystemContextMenuState();
|
||||
}
|
||||
|
||||
class _SystemContextMenuState extends State<SystemContextMenu> {
|
||||
late final SystemContextMenuController _systemContextMenuController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_systemContextMenuController =
|
||||
SystemContextMenuController(onSystemHide: widget.onSystemHide);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_systemContextMenuController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(SystemContextMenu.isSupported(context));
|
||||
|
||||
if (widget.items.isNotEmpty) {
|
||||
final WidgetsLocalizations localizations =
|
||||
WidgetsLocalizations.of(context);
|
||||
final List<IOSSystemContextMenuItemData> itemDatas = widget.items
|
||||
.map((IOSSystemContextMenuItem item) => item.getData(localizations))
|
||||
.toList();
|
||||
_systemContextMenuController.showWithItems(widget.anchor, itemDatas);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
/// Describes a context menu button that will be rendered in the iOS system
|
||||
/// context menu and not by Flutter itself.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [SystemContextMenu], a widget that can be used to display the system
|
||||
/// context menu.
|
||||
/// * [IOSSystemContextMenuItemData], which performs a similar role but at the
|
||||
/// method channel level and mirrors the requirements of the method channel
|
||||
/// API.
|
||||
/// * [ContextMenuButtonItem], which performs a similar role for Flutter-drawn
|
||||
/// context menus.
|
||||
@immutable
|
||||
sealed class IOSSystemContextMenuItem {
|
||||
const IOSSystemContextMenuItem();
|
||||
|
||||
/// The text to display to the user.
|
||||
///
|
||||
/// Not exposed for some built-in menu items whose title is always set by the
|
||||
/// platform.
|
||||
String? get title => null;
|
||||
|
||||
/// Returns the representation of this class used by method channels.
|
||||
IOSSystemContextMenuItemData getData(WidgetsLocalizations localizations);
|
||||
|
||||
@override
|
||||
int get hashCode => title.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
if (other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
return other is IOSSystemContextMenuItem && other.title == title;
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an instance of [IOSSystemContextMenuItem] for the system's built-in
|
||||
/// copy button.
|
||||
///
|
||||
/// Should only appear when there is a selection that can be copied.
|
||||
///
|
||||
/// The title and action are both handled by the platform.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [SystemContextMenu], a widget that can be used to display the system
|
||||
/// context menu.
|
||||
/// * [IOSSystemContextMenuItemDataCopy], which specifies the data to be sent to
|
||||
/// the platform for this same button.
|
||||
final class IOSSystemContextMenuItemCopy extends IOSSystemContextMenuItem {
|
||||
/// Creates an instance of [IOSSystemContextMenuItemCopy].
|
||||
const IOSSystemContextMenuItemCopy();
|
||||
|
||||
@override
|
||||
IOSSystemContextMenuItemDataCopy getData(WidgetsLocalizations localizations) {
|
||||
return const IOSSystemContextMenuItemDataCopy();
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an instance of [IOSSystemContextMenuItem] for the system's built-in
|
||||
/// cut button.
|
||||
///
|
||||
/// Should only appear when there is a selection that can be cut.
|
||||
///
|
||||
/// The title and action are both handled by the platform.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [SystemContextMenu], a widget that can be used to display the system
|
||||
/// context menu.
|
||||
/// * [IOSSystemContextMenuItemDataCut], which specifies the data to be sent to
|
||||
/// the platform for this same button.
|
||||
final class IOSSystemContextMenuItemCut extends IOSSystemContextMenuItem {
|
||||
/// Creates an instance of [IOSSystemContextMenuItemCut].
|
||||
const IOSSystemContextMenuItemCut();
|
||||
|
||||
@override
|
||||
IOSSystemContextMenuItemDataCut getData(WidgetsLocalizations localizations) {
|
||||
return const IOSSystemContextMenuItemDataCut();
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an instance of [IOSSystemContextMenuItem] for the system's built-in
|
||||
/// paste button.
|
||||
///
|
||||
/// Should only appear when the field can receive pasted content.
|
||||
///
|
||||
/// The title and action are both handled by the platform.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [SystemContextMenu], a widget that can be used to display the system
|
||||
/// context menu.
|
||||
/// * [IOSSystemContextMenuItemDataPaste], which specifies the data to be sent
|
||||
/// to the platform for this same button.
|
||||
final class IOSSystemContextMenuItemPaste extends IOSSystemContextMenuItem {
|
||||
/// Creates an instance of [IOSSystemContextMenuItemPaste].
|
||||
const IOSSystemContextMenuItemPaste();
|
||||
|
||||
@override
|
||||
IOSSystemContextMenuItemDataPaste getData(
|
||||
WidgetsLocalizations localizations) {
|
||||
return const IOSSystemContextMenuItemDataPaste();
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an instance of [IOSSystemContextMenuItem] for the system's built-in
|
||||
/// select all button.
|
||||
///
|
||||
/// Should only appear when the field can have its selection changed.
|
||||
///
|
||||
/// The title and action are both handled by the platform.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [SystemContextMenu], a widget that can be used to display the system
|
||||
/// context menu.
|
||||
/// * [IOSSystemContextMenuItemDataSelectAll], which specifies the data to be
|
||||
/// sent to the platform for this same button.
|
||||
final class IOSSystemContextMenuItemSelectAll extends IOSSystemContextMenuItem {
|
||||
/// Creates an instance of [IOSSystemContextMenuItemSelectAll].
|
||||
const IOSSystemContextMenuItemSelectAll();
|
||||
|
||||
@override
|
||||
IOSSystemContextMenuItemDataSelectAll getData(
|
||||
WidgetsLocalizations localizations) {
|
||||
return const IOSSystemContextMenuItemDataSelectAll();
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an instance of [IOSSystemContextMenuItem] for the
|
||||
/// system's built-in look up button.
|
||||
///
|
||||
/// Should only appear when content is selected.
|
||||
///
|
||||
/// The [title] is optional, but it must be specified before being sent to the
|
||||
/// platform. Typically it should be set to
|
||||
/// [WidgetsLocalizations.lookUpButtonLabel].
|
||||
///
|
||||
/// The action is handled by the platform.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [SystemContextMenu], a widget that can be used to display the system
|
||||
/// context menu.
|
||||
/// * [IOSSystemContextMenuItemDataLookUp], which specifies the data to be sent
|
||||
/// to the platform for this same button.
|
||||
final class IOSSystemContextMenuItemLookUp extends IOSSystemContextMenuItem {
|
||||
/// Creates an instance of [IOSSystemContextMenuItemLookUp].
|
||||
const IOSSystemContextMenuItemLookUp({this.title});
|
||||
|
||||
@override
|
||||
final String? title;
|
||||
|
||||
@override
|
||||
IOSSystemContextMenuItemDataLookUp getData(
|
||||
WidgetsLocalizations localizations) {
|
||||
return IOSSystemContextMenuItemDataLookUp(
|
||||
title: title ?? localizations.lookUpButtonLabel);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'IOSSystemContextMenuItemLookUp(title: $title)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an instance of [IOSSystemContextMenuItem] for the
|
||||
/// system's built-in search web button.
|
||||
///
|
||||
/// Should only appear when content is selected.
|
||||
///
|
||||
/// The [title] is optional, but it must be specified before being sent to the
|
||||
/// platform. Typically it should be set to
|
||||
/// [WidgetsLocalizations.searchWebButtonLabel].
|
||||
///
|
||||
/// The action is handled by the platform.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [SystemContextMenu], a widget that can be used to display the system
|
||||
/// context menu.
|
||||
/// * [IOSSystemContextMenuItemDataSearchWeb], which specifies the data to be
|
||||
/// sent to the platform for this same button.
|
||||
final class IOSSystemContextMenuItemSearchWeb extends IOSSystemContextMenuItem {
|
||||
/// Creates an instance of [IOSSystemContextMenuItemSearchWeb].
|
||||
const IOSSystemContextMenuItemSearchWeb({this.title});
|
||||
|
||||
@override
|
||||
final String? title;
|
||||
|
||||
@override
|
||||
IOSSystemContextMenuItemDataSearchWeb getData(
|
||||
WidgetsLocalizations localizations) {
|
||||
return IOSSystemContextMenuItemDataSearchWeb(
|
||||
title: title ?? localizations.searchWebButtonLabel,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'IOSSystemContextMenuItemSearchWeb(title: $title)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an instance of [IOSSystemContextMenuItem] for the
|
||||
/// system's built-in share button.
|
||||
///
|
||||
/// Opens the system share dialog.
|
||||
///
|
||||
/// Should only appear when shareable content is selected.
|
||||
///
|
||||
/// The [title] is optional, but it must be specified before being sent to the
|
||||
/// platform. Typically it should be set to
|
||||
/// [WidgetsLocalizations.shareButtonLabel].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [SystemContextMenu], a widget that can be used to display the system
|
||||
/// context menu.
|
||||
/// * [IOSSystemContextMenuItemDataShare], which specifies the data to be sent
|
||||
/// to the platform for this same button.
|
||||
final class IOSSystemContextMenuItemShare extends IOSSystemContextMenuItem {
|
||||
/// Creates an instance of [IOSSystemContextMenuItemShare].
|
||||
const IOSSystemContextMenuItemShare({this.title});
|
||||
|
||||
@override
|
||||
final String? title;
|
||||
|
||||
@override
|
||||
IOSSystemContextMenuItemDataShare getData(
|
||||
WidgetsLocalizations localizations) {
|
||||
return IOSSystemContextMenuItemDataShare(
|
||||
title: title ?? localizations.shareButtonLabel);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'IOSSystemContextMenuItemShare(title: $title)';
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(justinmc): Support the "custom" type.
|
||||
// https://github.com/flutter/flutter/issues/103163
|
||||
1962
lib/common/widgets/text_field/text_field.dart
Normal file
1962
lib/common/widgets/text_field/text_field.dart
Normal file
File diff suppressed because it is too large
Load Diff
1473
lib/common/widgets/text_field/text_selection.dart
Normal file
1473
lib/common/widgets/text_field/text_selection.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -915,4 +915,6 @@ class Api {
|
||||
static const String spaceComic = '${HttpString.appBaseUrl}/x/v2/space/comic';
|
||||
|
||||
static const String spaceAudio = '/audio/music-service/web/song/upper';
|
||||
|
||||
static const String dynMention = '/x/polymer/web-dynamic/v1/mention/search';
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import 'package:PiliPlus/models/dynamics/vote_model.dart';
|
||||
import 'package:PiliPlus/models_new/article/article_info/data.dart';
|
||||
import 'package:PiliPlus/models_new/article/article_list/data.dart';
|
||||
import 'package:PiliPlus/models_new/article/article_view/data.dart';
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_mention/data.dart';
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_mention/group.dart';
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_reserve/data.dart';
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_topic_feed/topic_card_list.dart';
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/top_details.dart';
|
||||
@@ -141,6 +143,7 @@ class DynamicsHttp {
|
||||
"dyn_req": {
|
||||
"content": {
|
||||
"contents": [
|
||||
if (rawText != null)
|
||||
{
|
||||
"raw_text": rawText,
|
||||
"type": 1,
|
||||
@@ -488,4 +491,22 @@ class DynamicsHttp {
|
||||
return Error(res.data['message']);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<LoadingState<List<MentionGroup>?>> dynMention(
|
||||
{String? keyword}) async {
|
||||
final res = await Request().get(
|
||||
Api.dynMention,
|
||||
queryParameters: {
|
||||
if (keyword?.isNotEmpty == true) 'keyword': keyword,
|
||||
'web_location': 333.1365,
|
||||
},
|
||||
);
|
||||
if (res.data['code'] == 0) {
|
||||
return Success(
|
||||
DynMentionData.fromJson(res.data['data']).groups,
|
||||
);
|
||||
} else {
|
||||
return Error(res.data['message']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,6 +510,7 @@ class VideoHttp {
|
||||
int? parent,
|
||||
List? pictures,
|
||||
bool? syncToDynamic,
|
||||
Map? atNameToMid,
|
||||
}) async {
|
||||
if (message == '') {
|
||||
return {'status': false, 'msg': '请输入评论内容'};
|
||||
@@ -520,13 +521,16 @@ class VideoHttp {
|
||||
if (root != null && root != 0) 'root': root,
|
||||
if (parent != null && parent != 0) 'parent': parent,
|
||||
'message': message,
|
||||
if (atNameToMid != null)
|
||||
'at_name_to_mid': jsonEncode(atNameToMid), // {"name":uid}
|
||||
if (pictures != null) 'pictures': jsonEncode(pictures),
|
||||
if (syncToDynamic == true) 'sync_to_dynamic': 1,
|
||||
'csrf': Accounts.main.csrf,
|
||||
};
|
||||
var res = await Request().post(
|
||||
Api.replyAdd,
|
||||
data: FormData.fromMap(data),
|
||||
data: data,
|
||||
options: Options(contentType: Headers.formUrlEncodedContentType),
|
||||
);
|
||||
log(res.toString());
|
||||
if (res.data['code'] == 0) {
|
||||
|
||||
13
lib/models_new/dynamic/dyn_mention/data.dart
Normal file
13
lib/models_new/dynamic/dyn_mention/data.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_mention/group.dart';
|
||||
|
||||
class DynMentionData {
|
||||
List<MentionGroup>? groups;
|
||||
|
||||
DynMentionData({this.groups});
|
||||
|
||||
factory DynMentionData.fromJson(Map<String, dynamic> json) => DynMentionData(
|
||||
groups: (json['groups'] as List<dynamic>?)
|
||||
?.map((e) => MentionGroup.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
17
lib/models_new/dynamic/dyn_mention/group.dart
Normal file
17
lib/models_new/dynamic/dyn_mention/group.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart';
|
||||
|
||||
class MentionGroup {
|
||||
String? groupName;
|
||||
int? groupType;
|
||||
List<MentionItem>? items;
|
||||
|
||||
MentionGroup({this.groupName, this.groupType, this.items});
|
||||
|
||||
factory MentionGroup.fromJson(Map<String, dynamic> json) => MentionGroup(
|
||||
groupName: json['group_name'] as String?,
|
||||
groupType: json['group_type'] as int?,
|
||||
items: (json['items'] as List<dynamic>?)
|
||||
?.map((e) => MentionItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
37
lib/models_new/dynamic/dyn_mention/item.dart
Normal file
37
lib/models_new/dynamic/dyn_mention/item.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
class MentionItem {
|
||||
String? face;
|
||||
int? fans;
|
||||
String? name;
|
||||
int? officialVerifyType;
|
||||
String? uid;
|
||||
|
||||
MentionItem({
|
||||
this.face,
|
||||
this.fans,
|
||||
this.name,
|
||||
this.officialVerifyType,
|
||||
this.uid,
|
||||
});
|
||||
|
||||
factory MentionItem.fromJson(Map<String, dynamic> json) => MentionItem(
|
||||
face: json['face'] as String?,
|
||||
fans: json['fans'] as int?,
|
||||
name: json['name'] as String?,
|
||||
officialVerifyType: json['official_verify_type'] as int?,
|
||||
uid: json['uid'] as String?,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
if (other is MentionItem) {
|
||||
return uid == other.uid;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => uid.hashCode;
|
||||
}
|
||||
@@ -625,7 +625,7 @@ class _ArticlePageState extends State<ArticlePage>
|
||||
upMid: _articleCtr.upMid,
|
||||
callback: _getImageCallback,
|
||||
onCheckReply: (item) =>
|
||||
_articleCtr.onCheckReply(context, item, isManual: true),
|
||||
_articleCtr.onCheckReply(item, isManual: true),
|
||||
onToggleTop: (item) => _articleCtr.onToggleTop(
|
||||
item,
|
||||
index,
|
||||
|
||||
@@ -6,12 +6,15 @@ import 'package:PiliPlus/common/widgets/button/icon_button.dart';
|
||||
import 'package:PiliPlus/http/msg.dart';
|
||||
import 'package:PiliPlus/models/common/image_preview_type.dart';
|
||||
import 'package:PiliPlus/models/common/publish_panel_type.dart';
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart';
|
||||
import 'package:PiliPlus/models_new/emote/emote.dart';
|
||||
import 'package:PiliPlus/models_new/live/live_emote/emoticon.dart';
|
||||
import 'package:PiliPlus/models_new/upload_bfs/data.dart';
|
||||
import 'package:PiliPlus/pages/dynamics_mention/view.dart';
|
||||
import 'package:PiliPlus/utils/extension.dart';
|
||||
import 'package:PiliPlus/utils/feed_back.dart';
|
||||
import 'package:chat_bottom_container/chat_bottom_container.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_debounce/easy_throttle.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -24,14 +27,16 @@ abstract class CommonPublishPage extends StatefulWidget {
|
||||
const CommonPublishPage({
|
||||
super.key,
|
||||
this.initialValue,
|
||||
this.mentions,
|
||||
this.imageLengthLimit,
|
||||
this.onSave,
|
||||
this.autofocus = true,
|
||||
});
|
||||
|
||||
final String? initialValue;
|
||||
final Set<MentionItem>? mentions;
|
||||
final int? imageLengthLimit;
|
||||
final ValueChanged<String>? onSave;
|
||||
final ValueChanged<({String text, Set<MentionItem>? mentions})>? onSave;
|
||||
final bool autofocus;
|
||||
}
|
||||
|
||||
@@ -49,11 +54,14 @@ abstract class CommonPublishPageState<T extends CommonPublishPage>
|
||||
late final RxList<String> pathList = <String>[].obs;
|
||||
int get limit => widget.imageLengthLimit ?? 9;
|
||||
|
||||
Set<MentionItem>? mentions;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
mentions = widget.mentions;
|
||||
if (widget.initialValue?.trim().isNotEmpty == true) {
|
||||
enablePublish.value = true;
|
||||
}
|
||||
@@ -221,7 +229,7 @@ abstract class CommonPublishPageState<T extends CommonPublishPage>
|
||||
offset: cursorPosition + emote.emoji!.length),
|
||||
);
|
||||
}
|
||||
widget.onSave?.call(editController.text);
|
||||
widget.onSave?.call((text: editController.text, mentions: mentions));
|
||||
}
|
||||
|
||||
Widget? get customPanel => null;
|
||||
@@ -387,4 +395,73 @@ abstract class CommonPublishPageState<T extends CommonPublishPage>
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>>? getRichContent() {
|
||||
if (mentions.isNullOrEmpty) {
|
||||
return null;
|
||||
}
|
||||
List<Map<String, dynamic>> content = [];
|
||||
void addPlainText(String text) {
|
||||
content.add({
|
||||
"raw_text": text,
|
||||
"type": 1,
|
||||
"biz_id": "",
|
||||
});
|
||||
}
|
||||
|
||||
final pattern =
|
||||
RegExp(mentions!.map((e) => RegExp.escape('@${e.name!}')).join('|'));
|
||||
|
||||
editController.text.splitMapJoin(
|
||||
pattern,
|
||||
onMatch: (Match match) {
|
||||
final name = match.group(0)!;
|
||||
final item =
|
||||
mentions!.firstWhereOrNull((e) => e.name == name.substring(1));
|
||||
if (item != null) {
|
||||
content.add({
|
||||
"raw_text": name,
|
||||
"type": 2,
|
||||
"biz_id": item.uid,
|
||||
});
|
||||
} else {
|
||||
addPlainText(name);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
onNonMatch: (String text) {
|
||||
addPlainText(text);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
return content;
|
||||
}
|
||||
|
||||
double _mentionOffset = 0;
|
||||
void onMention([bool fromClick = false]) {
|
||||
controller.keepChatPanel();
|
||||
DynMentionPanel.onDynMention(
|
||||
context,
|
||||
offset: _mentionOffset,
|
||||
callback: (offset) => _mentionOffset = offset,
|
||||
).then((MentionItem? res) {
|
||||
if (res != null) {
|
||||
enablePublish.value = true;
|
||||
|
||||
(mentions ??= <MentionItem>{}).add(res);
|
||||
|
||||
String atName = '${fromClick ? '@' : ''}${res.name} ';
|
||||
final int cursorPosition = editController.selection.baseOffset;
|
||||
final String currentText = editController.text;
|
||||
final String newText =
|
||||
'${currentText.substring(0, cursorPosition)}$atName${currentText.substring(cursorPosition)}';
|
||||
editController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection:
|
||||
TextSelection.collapsed(offset: cursorPosition + atName.length),
|
||||
);
|
||||
widget.onSave?.call((text: editController.text, mentions: mentions));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:PiliPlus/grpc/bilibili/pagination.pb.dart';
|
||||
import 'package:PiliPlus/http/loading_state.dart';
|
||||
import 'package:PiliPlus/http/reply.dart';
|
||||
import 'package:PiliPlus/models/common/reply/reply_sort_type.dart';
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart';
|
||||
import 'package:PiliPlus/pages/common/common_list_controller.dart';
|
||||
import 'package:PiliPlus/pages/video/reply_new/view.dart';
|
||||
import 'package:PiliPlus/services/account_service.dart';
|
||||
@@ -24,7 +25,8 @@ abstract class ReplyController<R> extends CommonListController<R, ReplyInfo> {
|
||||
late Rx<ReplySortType> sortType;
|
||||
late Rx<Mode> mode;
|
||||
|
||||
late final savedReplies = {};
|
||||
late final savedReplies =
|
||||
<Object, ({String text, Set<MentionItem>? mentions})?>{};
|
||||
|
||||
AccountService accountService = Get.find<AccountService>();
|
||||
|
||||
@@ -125,16 +127,16 @@ abstract class ReplyController<R> extends CommonListController<R, ReplyInfo> {
|
||||
.push(
|
||||
GetDialogRoute(
|
||||
pageBuilder: (buildContext, animation, secondaryAnimation) {
|
||||
final saved = savedReplies[key];
|
||||
return ReplyPage(
|
||||
oid: oid ?? replyItem!.oid.toInt(),
|
||||
root: oid != null ? 0 : replyItem!.id.toInt(),
|
||||
parent: oid != null ? 0 : replyItem!.id.toInt(),
|
||||
replyType: replyItem?.type.toInt() ?? replyType!,
|
||||
replyItem: replyItem,
|
||||
initialValue: savedReplies[key],
|
||||
onSave: (reply) {
|
||||
savedReplies[key] = reply;
|
||||
},
|
||||
initialValue: saved?.text,
|
||||
mentions: saved?.mentions,
|
||||
onSave: (reply) => savedReplies[key] = reply,
|
||||
hint: hint,
|
||||
);
|
||||
},
|
||||
@@ -157,7 +159,7 @@ abstract class ReplyController<R> extends CommonListController<R, ReplyInfo> {
|
||||
.then(
|
||||
(res) {
|
||||
if (res != null) {
|
||||
savedReplies[key] = null;
|
||||
savedReplies.remove(key);
|
||||
ReplyInfo replyInfo = RequestUtils.replyCast(res);
|
||||
if (loadingState.value.isSuccess) {
|
||||
List<ReplyInfo>? list = loadingState.value.data;
|
||||
@@ -179,8 +181,8 @@ abstract class ReplyController<R> extends CommonListController<R, ReplyInfo> {
|
||||
count.value += 1;
|
||||
|
||||
// check reply
|
||||
if (enableCommAntifraud && context.mounted) {
|
||||
onCheckReply(context, replyInfo, isManual: false);
|
||||
if (enableCommAntifraud) {
|
||||
onCheckReply(replyInfo, isManual: false);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -200,8 +202,7 @@ abstract class ReplyController<R> extends CommonListController<R, ReplyInfo> {
|
||||
loadingState.refresh();
|
||||
}
|
||||
|
||||
void onCheckReply(BuildContext context, ReplyInfo replyInfo,
|
||||
{required bool isManual}) {
|
||||
void onCheckReply(ReplyInfo replyInfo, {required bool isManual}) {
|
||||
ReplyUtils.onCheckReply(
|
||||
replyInfo: replyInfo,
|
||||
biliSendCommAntifraud: _biliSendCommAntifraud,
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:PiliPlus/common/constants.dart';
|
||||
import 'package:PiliPlus/common/widgets/button/icon_button.dart';
|
||||
import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart';
|
||||
import 'package:PiliPlus/common/widgets/custom_icon.dart';
|
||||
import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_dyn.dart'
|
||||
as dyn_sheet;
|
||||
import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_topic.dart'
|
||||
as topic_sheet;
|
||||
import 'package:PiliPlus/common/widgets/pair.dart';
|
||||
import 'package:PiliPlus/common/widgets/text_field/text_field.dart'
|
||||
as text_field;
|
||||
import 'package:PiliPlus/http/dynamics.dart';
|
||||
import 'package:PiliPlus/models/common/publish_panel_type.dart';
|
||||
import 'package:PiliPlus/models/common/reply/reply_option_type.dart';
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/topic_item.dart';
|
||||
import 'package:PiliPlus/pages/common/common_publish_page.dart';
|
||||
import 'package:PiliPlus/pages/dynamics_mention/controller.dart';
|
||||
import 'package:PiliPlus/pages/dynamics_select_topic/controller.dart';
|
||||
import 'package:PiliPlus/pages/dynamics_select_topic/view.dart';
|
||||
import 'package:PiliPlus/pages/emote/controller.dart';
|
||||
@@ -77,11 +76,10 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
|
||||
@override
|
||||
void dispose() {
|
||||
_titleEditCtr.dispose();
|
||||
try {
|
||||
Get
|
||||
..delete<EmotePanelController>()
|
||||
..delete<SelectTopicController>();
|
||||
} catch (_) {}
|
||||
..delete<SelectTopicController>()
|
||||
..delete<DynMentionController>();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -531,7 +529,10 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
|
||||
|
||||
Widget get _buildToolbar => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Obx(
|
||||
child: Row(
|
||||
spacing: 16,
|
||||
children: [
|
||||
Obx(
|
||||
() => ToolbarIconButton(
|
||||
onPressed: () => updatePanelType(
|
||||
panelType.value == PanelType.emoji
|
||||
@@ -543,6 +544,14 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
|
||||
selected: panelType.value == PanelType.emoji,
|
||||
),
|
||||
),
|
||||
ToolbarIconButton(
|
||||
onPressed: () => onMention(true),
|
||||
icon: const Icon(Icons.alternate_email, size: 22),
|
||||
tooltip: '@',
|
||||
selected: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildEditWidget(ThemeData theme) => Form(
|
||||
@@ -554,12 +563,14 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
|
||||
}
|
||||
},
|
||||
child: Obx(
|
||||
() => TextField(
|
||||
() => text_field.TextField(
|
||||
controller: editController,
|
||||
minLines: 4,
|
||||
maxLines: null,
|
||||
focusNode: focusNode,
|
||||
readOnly: readOnly.value,
|
||||
onDelAtUser: (name) =>
|
||||
mentions?.removeWhere((e) => e.name == name),
|
||||
onChanged: (value) {
|
||||
bool isEmpty = value.trim().isEmpty && pathList.isEmpty;
|
||||
if (!isEmpty && !enablePublish.value) {
|
||||
@@ -578,6 +589,7 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(1000)],
|
||||
onMention: onMention,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -590,9 +602,11 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
|
||||
Future<void> onCustomPublish(
|
||||
{required String message, List? pictures}) async {
|
||||
SmartDialog.showLoading(msg: '正在发布');
|
||||
List<Map<String, dynamic>>? extraContent = getRichContent();
|
||||
final hasMention = extraContent != null;
|
||||
var result = await DynamicsHttp.createDynamic(
|
||||
mid: Accounts.main.mid,
|
||||
rawText: editController.text,
|
||||
rawText: hasMention ? null : editController.text,
|
||||
pics: pictures,
|
||||
publishTime: _publishTime.value != null
|
||||
? _publishTime.value!.millisecondsSinceEpoch ~/ 1000
|
||||
@@ -601,6 +615,7 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
|
||||
privatePub: _isPrivate.value ? 1 : null,
|
||||
title: _titleEditCtr.text,
|
||||
topic: topic.value,
|
||||
extraContent: extraContent,
|
||||
);
|
||||
SmartDialog.dismiss();
|
||||
if (result['status']) {
|
||||
@@ -618,31 +633,16 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
|
||||
}
|
||||
}
|
||||
|
||||
double _offset = 0;
|
||||
Future<void> _onSelectTopic() async {
|
||||
TopicItem? res = await showModalBottomSheet(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
isScrollControlled: true,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: min(600, context.mediaQueryShortestSide),
|
||||
),
|
||||
builder: (context) => topic_sheet.DraggableScrollableSheet(
|
||||
expand: false,
|
||||
snap: true,
|
||||
minChildSize: 0,
|
||||
maxChildSize: 1,
|
||||
initialChildSize: _offset == 0 ? 0.65 : 1,
|
||||
initialScrollOffset: _offset,
|
||||
snapSizes: const [0.65],
|
||||
builder: (context, scrollController) => SelectTopicPanel(
|
||||
scrollController: scrollController,
|
||||
callback: (offset) => _offset = offset,
|
||||
),
|
||||
),
|
||||
);
|
||||
double _topicOffset = 0;
|
||||
void _onSelectTopic() {
|
||||
SelectTopicPanel.onSelectTopic(
|
||||
context,
|
||||
offset: _topicOffset,
|
||||
callback: (offset) => _topicOffset = offset,
|
||||
).then((TopicItem? res) {
|
||||
if (res != null) {
|
||||
topic.value = Pair(first: res.id, second: res.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,7 +736,7 @@ class _DynamicDetailPageState extends State<DynamicDetailPage>
|
||||
upMid: _controller.upMid,
|
||||
callback: _getImageCallback,
|
||||
onCheckReply: (item) =>
|
||||
_controller.onCheckReply(context, item, isManual: true),
|
||||
_controller.onCheckReply(item, isManual: true),
|
||||
onToggleTop: (item) => _controller.onToggleTop(
|
||||
item,
|
||||
index,
|
||||
|
||||
31
lib/pages/dynamics_mention/controller.dart
Normal file
31
lib/pages/dynamics_mention/controller.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:PiliPlus/http/dynamics.dart';
|
||||
import 'package:PiliPlus/http/loading_state.dart';
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_mention/group.dart';
|
||||
import 'package:PiliPlus/pages/common/common_list_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class DynMentionController
|
||||
extends CommonListController<List<MentionGroup>?, MentionGroup> {
|
||||
final focusNode = FocusNode();
|
||||
final controller = TextEditingController();
|
||||
|
||||
final RxBool enableClear = false.obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
queryData();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LoadingState<List<MentionGroup>?>> customGetData() =>
|
||||
DynamicsHttp.dynMention(keyword: controller.text);
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
focusNode.dispose();
|
||||
controller.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
235
lib/pages/dynamics_mention/view.dart
Normal file
235
lib/pages/dynamics_mention/view.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_topic.dart'
|
||||
as topic_sheet;
|
||||
import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart';
|
||||
import 'package:PiliPlus/http/loading_state.dart';
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_mention/group.dart';
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart';
|
||||
import 'package:PiliPlus/pages/dynamics_mention/controller.dart';
|
||||
import 'package:PiliPlus/pages/dynamics_mention/widgets/item.dart';
|
||||
import 'package:PiliPlus/utils/extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:stream_transform/stream_transform.dart';
|
||||
|
||||
class DynMentionPanel extends StatefulWidget {
|
||||
const DynMentionPanel({
|
||||
super.key,
|
||||
this.scrollController,
|
||||
this.callback,
|
||||
});
|
||||
|
||||
final ScrollController? scrollController;
|
||||
final ValueChanged<double>? callback;
|
||||
|
||||
static Future<MentionItem?> onDynMention(
|
||||
BuildContext context, {
|
||||
double offset = 0,
|
||||
ValueChanged<double>? callback,
|
||||
}) {
|
||||
return showModalBottomSheet<MentionItem?>(
|
||||
context: Get.context!,
|
||||
useSafeArea: true,
|
||||
isScrollControlled: true,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: min(600, context.mediaQueryShortestSide),
|
||||
),
|
||||
builder: (context) => topic_sheet.DraggableScrollableSheet(
|
||||
expand: false,
|
||||
snap: true,
|
||||
minChildSize: 0,
|
||||
maxChildSize: 1,
|
||||
initialChildSize: offset == 0 ? 0.65 : 1,
|
||||
initialScrollOffset: offset,
|
||||
snapSizes: const [0.65],
|
||||
builder: (context, scrollController) => DynMentionPanel(
|
||||
scrollController: scrollController,
|
||||
callback: callback,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<DynMentionPanel> createState() => _DynMentionPanelState();
|
||||
}
|
||||
|
||||
class _DynMentionPanelState extends State<DynMentionPanel> {
|
||||
final _controller = Get.put(DynMentionController());
|
||||
final StreamController<String> _ctr = StreamController<String>();
|
||||
late StreamSubscription<String> _sub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_controller.loadingState.value is Error) {
|
||||
_controller.onReload();
|
||||
}
|
||||
_sub = _ctr.stream
|
||||
.debounce(const Duration(milliseconds: 300), trailing: true)
|
||||
.listen((value) {
|
||||
_controller
|
||||
..enableClear.value = value.isNotEmpty
|
||||
..onRefresh().whenComplete(() => WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => widget.scrollController?.jumpToTop()));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub.cancel();
|
||||
_ctr.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 35,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.outline,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(3)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 5),
|
||||
child: TextField(
|
||||
focusNode: _controller.focusNode,
|
||||
controller: _controller.controller,
|
||||
onChanged: _ctr.add,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(
|
||||
gapPadding: 0,
|
||||
borderSide: BorderSide.none,
|
||||
borderRadius: BorderRadius.all(Radius.circular(25)),
|
||||
),
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.onInverseSurface,
|
||||
hintText: '输入你想@的人',
|
||||
hintStyle: const TextStyle(fontSize: 14),
|
||||
prefixIcon: const Padding(
|
||||
padding: EdgeInsets.only(left: 12, right: 4),
|
||||
child: Icon(Icons.search, size: 20),
|
||||
),
|
||||
prefixIconConstraints:
|
||||
const BoxConstraints(minHeight: 0, minWidth: 0),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
suffixIcon: Obx(
|
||||
() => _controller.enableClear.value
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: GestureDetector(
|
||||
child: Container(
|
||||
padding: const EdgeInsetsDirectional.all(2),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.clear,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
onTap: () => _controller
|
||||
..enableClear.value = false
|
||||
..controller.clear()
|
||||
..onRefresh().whenComplete(() => WidgetsBinding
|
||||
.instance
|
||||
.addPostFrameCallback((_) =>
|
||||
widget.scrollController?.jumpToTop())),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
suffixIconConstraints:
|
||||
const BoxConstraints(minHeight: 0, minWidth: 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Obx(() => _buildBody(theme, _controller.loadingState.value)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(
|
||||
ThemeData theme, LoadingState<List<MentionGroup>?> loadingState) {
|
||||
return switch (loadingState) {
|
||||
Loading() => loadingWidget,
|
||||
Success<List<MentionGroup>?>(:var response) =>
|
||||
response?.isNotEmpty == true
|
||||
? NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification is UserScrollNotification) {
|
||||
if (_controller.focusNode.hasFocus) {
|
||||
_controller.focusNode.unfocus();
|
||||
}
|
||||
} else if (notification is ScrollEndNotification) {
|
||||
widget.callback?.call(notification.metrics.pixels);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
slivers: [
|
||||
...response!.map((group) {
|
||||
if (group.items!.isNullOrEmpty) {
|
||||
return const SliverToBoxAdapter();
|
||||
}
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 6),
|
||||
child: Text(group.groupName!),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: group.items!.length,
|
||||
itemBuilder: (context, index) {
|
||||
return DynMentionItem(
|
||||
item: group.items![index],
|
||||
onTap: (e) => Get.back(result: e),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.paddingOf(context).bottom +
|
||||
MediaQuery.viewInsetsOf(context).bottom +
|
||||
80,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _errWidget(),
|
||||
Error(:var errMsg) => _errWidget(errMsg),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _errWidget([String? errMsg]) => scrollErrorWidget(
|
||||
errMsg: errMsg,
|
||||
controller: widget.scrollController,
|
||||
onReload: _controller.onReload,
|
||||
);
|
||||
}
|
||||
41
lib/pages/dynamics_mention/widgets/item.dart
Normal file
41
lib/pages/dynamics_mention/widgets/item.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
|
||||
import 'package:PiliPlus/models/common/image_type.dart';
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart';
|
||||
import 'package:PiliPlus/utils/num_util.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DynMentionItem extends StatelessWidget {
|
||||
const DynMentionItem({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final MentionItem item;
|
||||
final ValueChanged<MentionItem> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
onTap: () => onTap(item),
|
||||
leading: NetworkImgLayer(
|
||||
src: item.face,
|
||||
width: 42,
|
||||
height: 42,
|
||||
type: ImageType.avatar,
|
||||
),
|
||||
title: Text(
|
||||
item.name!,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${NumUtil.numFormat(item.fans)}粉丝',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.outline),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,17 @@ import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart';
|
||||
import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_dyn.dart'
|
||||
show DraggableScrollableSheet;
|
||||
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
|
||||
import 'package:PiliPlus/common/widgets/text_field/text_field.dart';
|
||||
import 'package:PiliPlus/http/dynamics.dart';
|
||||
import 'package:PiliPlus/models/common/publish_panel_type.dart';
|
||||
import 'package:PiliPlus/models/dynamics/result.dart';
|
||||
import 'package:PiliPlus/pages/common/common_publish_page.dart';
|
||||
import 'package:PiliPlus/pages/dynamics_mention/controller.dart';
|
||||
import 'package:PiliPlus/pages/emote/controller.dart';
|
||||
import 'package:PiliPlus/pages/emote/view.dart';
|
||||
import 'package:PiliPlus/utils/accounts.dart';
|
||||
import 'package:PiliPlus/utils/request_utils.dart';
|
||||
import 'package:flutter/material.dart' hide DraggableScrollableSheet;
|
||||
import 'package:flutter/material.dart' hide DraggableScrollableSheet, TextField;
|
||||
import 'package:flutter/services.dart' show LengthLimitingTextInputFormatter;
|
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -68,9 +70,9 @@ class _RepostPanelState extends CommonPublishPageState<RepostPanel> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
try {
|
||||
Get.delete<EmotePanelController>();
|
||||
} catch (_) {}
|
||||
Get
|
||||
..delete<EmotePanelController>()
|
||||
..delete<DynMentionController>();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -239,6 +241,7 @@ class _RepostPanelState extends CommonPublishPageState<RepostPanel> {
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||
),
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(1000)],
|
||||
onMention: onMention,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -323,7 +326,10 @@ class _RepostPanelState extends CommonPublishPageState<RepostPanel> {
|
||||
|
||||
Widget get _buildToolbar => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Obx(
|
||||
child: Row(
|
||||
spacing: 16,
|
||||
children: [
|
||||
Obx(
|
||||
() => ToolbarIconButton(
|
||||
onPressed: () {
|
||||
updatePanelType(
|
||||
@@ -337,6 +343,14 @@ class _RepostPanelState extends CommonPublishPageState<RepostPanel> {
|
||||
selected: panelType.value == PanelType.emoji,
|
||||
),
|
||||
),
|
||||
ToolbarIconButton(
|
||||
onPressed: () => onMention(true),
|
||||
icon: const Icon(Icons.alternate_email, size: 22),
|
||||
tooltip: '@',
|
||||
selected: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> _biuldDismiss(ThemeData theme) => [
|
||||
@@ -403,15 +417,23 @@ class _RepostPanelState extends CommonPublishPageState<RepostPanel> {
|
||||
@override
|
||||
Future<void> onCustomPublish(
|
||||
{required String message, List? pictures}) async {
|
||||
SmartDialog.showLoading();
|
||||
List<Map<String, dynamic>>? content = getRichContent();
|
||||
final hasMention = content != null;
|
||||
List<Map<String, dynamic>>? repostContent =
|
||||
widget.item?.orig != null ? extraContent(widget.item!) : null;
|
||||
if (hasMention && repostContent != null) {
|
||||
content.addAll(repostContent);
|
||||
}
|
||||
var result = await DynamicsHttp.createDynamic(
|
||||
mid: Accounts.main.mid,
|
||||
dynIdStr: widget.item?.idStr ?? widget.dynIdStr,
|
||||
rid: widget.rid,
|
||||
dynType: widget.dynType,
|
||||
rawText: editController.text,
|
||||
extraContent:
|
||||
widget.item?.orig != null ? extraContent(widget.item!) : null,
|
||||
rawText: hasMention ? null : editController.text,
|
||||
extraContent: content ?? repostContent,
|
||||
);
|
||||
SmartDialog.dismiss();
|
||||
if (result['status']) {
|
||||
Get.back();
|
||||
SmartDialog.showToast('转发成功');
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_topic.dart'
|
||||
as topic_sheet;
|
||||
import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart';
|
||||
import 'package:PiliPlus/http/loading_state.dart';
|
||||
import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/topic_item.dart';
|
||||
@@ -20,6 +23,34 @@ class SelectTopicPanel extends StatefulWidget {
|
||||
final ScrollController? scrollController;
|
||||
final ValueChanged<double>? callback;
|
||||
|
||||
static Future<TopicItem?> onSelectTopic(
|
||||
BuildContext context, {
|
||||
double offset = 0,
|
||||
ValueChanged<double>? callback,
|
||||
}) {
|
||||
return showModalBottomSheet<TopicItem?>(
|
||||
context: Get.context!,
|
||||
useSafeArea: true,
|
||||
isScrollControlled: true,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: min(600, context.mediaQueryShortestSide),
|
||||
),
|
||||
builder: (context) => topic_sheet.DraggableScrollableSheet(
|
||||
expand: false,
|
||||
snap: true,
|
||||
minChildSize: 0,
|
||||
maxChildSize: 1,
|
||||
initialChildSize: offset == 0 ? 0.65 : 1,
|
||||
initialScrollOffset: offset,
|
||||
snapSizes: const [0.65],
|
||||
builder: (context, scrollController) => SelectTopicPanel(
|
||||
scrollController: scrollController,
|
||||
callback: callback,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<SelectTopicPanel> createState() => _SelectTopicPanelState();
|
||||
}
|
||||
|
||||
@@ -607,7 +607,7 @@ class _LiveRoomPageState extends State<LiveRoomPage>
|
||||
fromEmote: fromEmote,
|
||||
liveRoomController: _liveRoomController,
|
||||
initialValue: _liveRoomController.savedDanmaku,
|
||||
onSave: (msg) => _liveRoomController.savedDanmaku = msg,
|
||||
onSave: (msg) => _liveRoomController.savedDanmaku = msg.text,
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
|
||||
@@ -217,8 +217,8 @@ class _MatchInfoPageState extends State<MatchInfoPage> {
|
||||
onDelete: (item, subIndex) =>
|
||||
_controller.onRemove(index, item, subIndex),
|
||||
upMid: _controller.upMid,
|
||||
onCheckReply: (item) => _controller
|
||||
.onCheckReply(context, item, isManual: true),
|
||||
onCheckReply: (item) =>
|
||||
_controller.onCheckReply(item, isManual: true),
|
||||
onToggleTop: (item) => _controller.onToggleTop(
|
||||
item,
|
||||
index,
|
||||
|
||||
@@ -945,7 +945,7 @@ class VideoDetailController extends GetxController
|
||||
bvid: bvid,
|
||||
progress: plPlayerController.position.value.inMilliseconds,
|
||||
initialValue: savedDanmaku,
|
||||
onSave: (danmaku) => savedDanmaku = danmaku,
|
||||
onSave: (danmaku) => savedDanmaku = danmaku.text,
|
||||
callback: (danmakuModel) {
|
||||
savedDanmaku = null;
|
||||
plPlayerController.danmakuController?.addDanmaku(danmakuModel);
|
||||
|
||||
@@ -229,7 +229,7 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
|
||||
onDismissed: widget.onDismissed,
|
||||
callback: widget.callback,
|
||||
onCheckReply: (item) => _videoReplyController
|
||||
.onCheckReply(context, item, isManual: true),
|
||||
.onCheckReply(item, isManual: true),
|
||||
onToggleTop: (item) => _videoReplyController.onToggleTop(
|
||||
item,
|
||||
index,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart';
|
||||
import 'package:PiliPlus/common/widgets/text_field/text_field.dart';
|
||||
import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart'
|
||||
show ReplyInfo;
|
||||
import 'package:PiliPlus/http/video.dart';
|
||||
import 'package:PiliPlus/main.dart';
|
||||
import 'package:PiliPlus/models/common/publish_panel_type.dart';
|
||||
import 'package:PiliPlus/pages/common/common_publish_page.dart';
|
||||
import 'package:PiliPlus/pages/dynamics_mention/controller.dart';
|
||||
import 'package:PiliPlus/pages/emote/view.dart';
|
||||
import 'package:PiliPlus/utils/storage_pref.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/material.dart' hide TextField;
|
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
@@ -24,6 +26,7 @@ class ReplyPage extends CommonPublishPage {
|
||||
const ReplyPage({
|
||||
super.key,
|
||||
super.initialValue,
|
||||
super.mentions,
|
||||
super.imageLengthLimit,
|
||||
super.onSave,
|
||||
required this.oid,
|
||||
@@ -66,6 +69,12 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
Get.delete<DynMentionController>();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -141,7 +150,7 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
|
||||
} else if (isEmpty && enablePublish.value) {
|
||||
enablePublish.value = false;
|
||||
}
|
||||
widget.onSave?.call(value);
|
||||
widget.onSave?.call((text: value, mentions: mentions));
|
||||
},
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
@@ -150,6 +159,7 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
|
||||
hintStyle: const TextStyle(fontSize: 14),
|
||||
),
|
||||
style: themeData.textTheme.bodyLarge,
|
||||
onMention: onMention,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -163,7 +173,6 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
|
||||
height: 52,
|
||||
padding: const EdgeInsets.only(left: 12, right: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Obx(
|
||||
() => ToolbarIconButton(
|
||||
@@ -177,7 +186,7 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
|
||||
selected: panelType.value == PanelType.keyboard,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 8),
|
||||
Obx(
|
||||
() => ToolbarIconButton(
|
||||
tooltip: '表情',
|
||||
@@ -191,7 +200,7 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
|
||||
),
|
||||
),
|
||||
if (widget.root == 0) ...[
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 8),
|
||||
ToolbarIconButton(
|
||||
tooltip: '图片',
|
||||
selected: false,
|
||||
@@ -199,32 +208,56 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
|
||||
onPressed: onPickImage,
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
Obx(
|
||||
() => TextButton.icon(
|
||||
const SizedBox(width: 8),
|
||||
ToolbarIconButton(
|
||||
onPressed: () => onMention(true),
|
||||
icon: const Icon(Icons.alternate_email, size: 22),
|
||||
tooltip: '@',
|
||||
selected: false,
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Obx(
|
||||
() => TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.all(13),
|
||||
visualDensity: VisualDensity.compact,
|
||||
foregroundColor: _syncToDynamic.value
|
||||
? themeData.colorScheme.secondary
|
||||
: themeData.colorScheme.outline,
|
||||
),
|
||||
onPressed: () => _syncToDynamic.value = !_syncToDynamic.value,
|
||||
icon: Icon(
|
||||
onPressed: () =>
|
||||
_syncToDynamic.value = !_syncToDynamic.value,
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_syncToDynamic.value
|
||||
? Icons.check_box
|
||||
: Icons.check_box_outline_blank,
|
||||
size: 22,
|
||||
),
|
||||
label: const Text('转发至动态'),
|
||||
const Flexible(
|
||||
child: Text(
|
||||
'转到动态',
|
||||
maxLines: 1,
|
||||
style: TextStyle(height: 1),
|
||||
strutStyle: StrutStyle(leading: 0, height: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Obx(
|
||||
() => FilledButton.tonal(
|
||||
onPressed: enablePublish.value ? onPublish : null,
|
||||
style: FilledButton.styleFrom(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
visualDensity: VisualDensity.compact,
|
||||
@@ -249,6 +282,9 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
|
||||
message: widget.replyItem != null && widget.replyItem!.root != 0
|
||||
? ' 回复 @${widget.replyItem!.member.name} : $message'
|
||||
: message,
|
||||
atNameToMid: mentions?.isNotEmpty == true
|
||||
? {for (var e in mentions!) e.name: e.uid}
|
||||
: null,
|
||||
pictures: pictures,
|
||||
syncToDynamic: _syncToDynamic.value,
|
||||
);
|
||||
|
||||
@@ -3,10 +3,13 @@ import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart'
|
||||
import 'package:PiliPlus/grpc/reply.dart';
|
||||
import 'package:PiliPlus/http/loading_state.dart';
|
||||
import 'package:PiliPlus/pages/common/reply_controller.dart';
|
||||
import 'package:PiliPlus/pages/video/reply_new/view.dart';
|
||||
import 'package:PiliPlus/utils/id_utils.dart';
|
||||
import 'package:PiliPlus/utils/request_utils.dart';
|
||||
import 'package:PiliPlus/utils/storage_pref.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get/get_navigation/src/dialog/dialog_route.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class VideoReplyReplyController extends ReplyController
|
||||
@@ -129,6 +132,67 @@ class VideoReplyReplyController extends ReplyController
|
||||
onReload();
|
||||
}
|
||||
|
||||
@override
|
||||
void onReply(
|
||||
BuildContext context, {
|
||||
int? oid,
|
||||
ReplyInfo? replyItem,
|
||||
int? replyType,
|
||||
int? index,
|
||||
}) {
|
||||
assert(replyItem != null && index != null);
|
||||
final oid = replyItem!.oid.toInt();
|
||||
final root = replyItem.id.toInt();
|
||||
final key = oid + root;
|
||||
|
||||
Navigator.of(context)
|
||||
.push(
|
||||
GetDialogRoute(
|
||||
pageBuilder: (buildContext, animation, secondaryAnimation) {
|
||||
final saved = savedReplies[key];
|
||||
return ReplyPage(
|
||||
oid: oid,
|
||||
root: root,
|
||||
parent: root,
|
||||
replyType: this.replyType,
|
||||
replyItem: replyItem,
|
||||
initialValue: saved?.text,
|
||||
mentions: saved?.mentions,
|
||||
onSave: (reply) => savedReplies[key] = reply,
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) {
|
||||
const begin = Offset(0.0, 1.0);
|
||||
const end = Offset.zero;
|
||||
const curve = Curves.linear;
|
||||
|
||||
var tween =
|
||||
Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
|
||||
|
||||
return SlideTransition(
|
||||
position: animation.drive(tween),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.then((res) {
|
||||
if (res != null) {
|
||||
savedReplies.remove(key);
|
||||
ReplyInfo replyInfo = RequestUtils.replyCast(res);
|
||||
|
||||
count.value += 1;
|
||||
loadingState
|
||||
..value.dataOrNull?.insert(index! + 1, replyInfo)
|
||||
..refresh();
|
||||
if (enableCommAntifraud) {
|
||||
onCheckReply(replyInfo, isManual: false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
controller?.dispose();
|
||||
|
||||
@@ -6,15 +6,12 @@ import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart'
|
||||
import 'package:PiliPlus/http/loading_state.dart';
|
||||
import 'package:PiliPlus/pages/common/common_slide_page.dart';
|
||||
import 'package:PiliPlus/pages/video/reply/widgets/reply_item_grpc.dart';
|
||||
import 'package:PiliPlus/pages/video/reply_new/view.dart';
|
||||
import 'package:PiliPlus/pages/video/reply_reply/controller.dart';
|
||||
import 'package:PiliPlus/utils/num_util.dart';
|
||||
import 'package:PiliPlus/utils/page_utils.dart';
|
||||
import 'package:PiliPlus/utils/request_utils.dart';
|
||||
import 'package:PiliPlus/utils/utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get/get_navigation/src/dialog/dialog_route.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class VideoReplyReplyPanel extends CommonSlidePage {
|
||||
@@ -51,27 +48,25 @@ class VideoReplyReplyPanel extends CommonSlidePage {
|
||||
|
||||
class _VideoReplyReplyPanelState
|
||||
extends CommonSlidePageState<VideoReplyReplyPanel> {
|
||||
late VideoReplyReplyController _videoReplyReplyController;
|
||||
late final _savedReplies = <int, String>{};
|
||||
late VideoReplyReplyController _controller;
|
||||
late final itemPositionsListener = ItemPositionsListener.create();
|
||||
late final _key = GlobalKey<ScaffoldState>();
|
||||
late final _listKey = GlobalKey();
|
||||
late final _tag =
|
||||
Utils.makeHeroTag('${widget.rpid}${widget.dialog}${widget.isDialogue}');
|
||||
|
||||
ReplyInfo? get firstFloor =>
|
||||
widget.firstFloor ?? _videoReplyReplyController.firstFloor;
|
||||
ReplyInfo? get firstFloor => widget.firstFloor ?? _controller.firstFloor;
|
||||
|
||||
bool get _horizontalPreview =>
|
||||
context.orientation == Orientation.landscape &&
|
||||
_videoReplyReplyController.horizontalPreview;
|
||||
_controller.horizontalPreview;
|
||||
|
||||
Animation<Color?>? colorAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_videoReplyReplyController = Get.put(
|
||||
_controller = Get.put(
|
||||
VideoReplyReplyController(
|
||||
hasRoot: widget.firstFloor != null,
|
||||
id: widget.id,
|
||||
@@ -159,7 +154,7 @@ class _VideoReplyReplyPanelState
|
||||
Widget buildList(ThemeData theme) {
|
||||
return ClipRect(
|
||||
child: refreshIndicator(
|
||||
onRefresh: _videoReplyReplyController.onRefresh,
|
||||
onRefresh: _controller.onRefresh,
|
||||
child: Obx(
|
||||
() => Stack(
|
||||
clipBehavior: Clip.none,
|
||||
@@ -167,27 +162,27 @@ class _VideoReplyReplyPanelState
|
||||
ScrollablePositionedList.builder(
|
||||
key: _listKey,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
itemCount:
|
||||
_itemCount(_videoReplyReplyController.loadingState.value),
|
||||
itemScrollController: _videoReplyReplyController.itemScrollCtr,
|
||||
itemCount: _itemCount(_controller.loadingState.value),
|
||||
itemScrollController: _controller.itemScrollCtr,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
if (widget.isDialogue) {
|
||||
return _buildBody(theme,
|
||||
_videoReplyReplyController.loadingState.value, index);
|
||||
return _buildBody(
|
||||
theme, _controller.loadingState.value, index);
|
||||
} else if (firstFloor != null) {
|
||||
if (index == 0) {
|
||||
return ReplyItemGrpc(
|
||||
replyItem: firstFloor!,
|
||||
replyLevel: 2,
|
||||
needDivider: false,
|
||||
onReply: (replyItem) => _onReply(replyItem, -1),
|
||||
upMid: _videoReplyReplyController.upMid,
|
||||
onReply: (replyItem) => _controller.onReply(context,
|
||||
replyItem: replyItem, index: -1),
|
||||
upMid: _controller.upMid,
|
||||
onViewImage: widget.onViewImage,
|
||||
onDismissed: widget.onDismissed,
|
||||
callback: _getImageCallback,
|
||||
onCheckReply: (item) => _videoReplyReplyController
|
||||
.onCheckReply(context, item, isManual: true),
|
||||
onCheckReply: (item) =>
|
||||
_controller.onCheckReply(item, isManual: true),
|
||||
);
|
||||
} else if (index == 1) {
|
||||
return Divider(
|
||||
@@ -200,7 +195,7 @@ class _VideoReplyReplyPanelState
|
||||
} else {
|
||||
return _buildBody(
|
||||
theme,
|
||||
_videoReplyReplyController.loadingState.value,
|
||||
_controller.loadingState.value,
|
||||
index - 3,
|
||||
);
|
||||
}
|
||||
@@ -210,7 +205,7 @@ class _VideoReplyReplyPanelState
|
||||
} else {
|
||||
return _buildBody(
|
||||
theme,
|
||||
_videoReplyReplyController.loadingState.value,
|
||||
_controller.loadingState.value,
|
||||
index - 1,
|
||||
);
|
||||
}
|
||||
@@ -218,7 +213,7 @@ class _VideoReplyReplyPanelState
|
||||
},
|
||||
),
|
||||
if (!widget.isDialogue &&
|
||||
_videoReplyReplyController.loadingState.value.isSuccess)
|
||||
_controller.loadingState.value.isSuccess)
|
||||
_header(theme),
|
||||
],
|
||||
),
|
||||
@@ -243,9 +238,9 @@ class _VideoReplyReplyPanelState
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Obx(
|
||||
() => _videoReplyReplyController.count.value != -1
|
||||
() => _controller.count.value != -1
|
||||
? Text(
|
||||
'相关回复共${NumUtil.numFormat(_videoReplyReplyController.count.value)}条',
|
||||
'相关回复共${NumUtil.numFormat(_controller.count.value)}条',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
@@ -253,7 +248,7 @@ class _VideoReplyReplyPanelState
|
||||
SizedBox(
|
||||
height: 35,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _videoReplyReplyController.queryBySort(),
|
||||
onPressed: () => _controller.queryBySort(),
|
||||
icon: Icon(
|
||||
Icons.sort,
|
||||
size: 16,
|
||||
@@ -261,7 +256,7 @@ class _VideoReplyReplyPanelState
|
||||
),
|
||||
label: Obx(
|
||||
() => Text(
|
||||
_videoReplyReplyController.mode.value == Mode.MAIN_LIST_HOT
|
||||
_controller.mode.value == Mode.MAIN_LIST_HOT
|
||||
? '按热度'
|
||||
: '按时间',
|
||||
style: TextStyle(
|
||||
@@ -306,59 +301,6 @@ class _VideoReplyReplyPanelState
|
||||
}
|
||||
: null;
|
||||
|
||||
void _onReply(ReplyInfo item, int index) {
|
||||
final oid = item.oid.toInt();
|
||||
final root = item.id.toInt();
|
||||
final key = oid + root;
|
||||
|
||||
Navigator.of(context)
|
||||
.push(
|
||||
GetDialogRoute(
|
||||
pageBuilder: (buildContext, animation, secondaryAnimation) {
|
||||
return ReplyPage(
|
||||
oid: oid,
|
||||
root: root,
|
||||
parent: root,
|
||||
replyType: widget.replyType,
|
||||
replyItem: item,
|
||||
initialValue: _savedReplies[key],
|
||||
onSave: (reply) {
|
||||
_savedReplies[key] = reply;
|
||||
},
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) {
|
||||
const begin = Offset(0.0, 1.0);
|
||||
const end = Offset.zero;
|
||||
const curve = Curves.linear;
|
||||
|
||||
var tween =
|
||||
Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
|
||||
|
||||
return SlideTransition(
|
||||
position: animation.drive(tween),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.then((res) {
|
||||
if (res != null) {
|
||||
_savedReplies.remove(key);
|
||||
ReplyInfo replyInfo = RequestUtils.replyCast(res);
|
||||
_videoReplyReplyController
|
||||
..count.value += 1
|
||||
..loadingState.value.dataOrNull?.insert(index + 1, replyInfo)
|
||||
..loadingState.refresh();
|
||||
if (_videoReplyReplyController.enableCommAntifraud && mounted) {
|
||||
_videoReplyReplyController.onCheckReply(context, replyInfo,
|
||||
isManual: false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildBody(
|
||||
ThemeData theme, LoadingState<List<ReplyInfo>?> loadingState, int index) {
|
||||
return switch (loadingState) {
|
||||
@@ -375,14 +317,14 @@ class _VideoReplyReplyPanelState
|
||||
Success(:var response) => Builder(
|
||||
builder: (context) {
|
||||
if (index == response!.length) {
|
||||
_videoReplyReplyController.onLoadMore();
|
||||
_controller.onLoadMore();
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
margin: EdgeInsets.only(
|
||||
bottom: MediaQuery.paddingOf(context).bottom),
|
||||
height: 125,
|
||||
child: Text(
|
||||
_videoReplyReplyController.isEnd ? '没有更多了' : '加载中...',
|
||||
_controller.isEnd ? '没有更多了' : '加载中...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.outline,
|
||||
@@ -390,12 +332,11 @@ class _VideoReplyReplyPanelState
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (_videoReplyReplyController.index != null &&
|
||||
_videoReplyReplyController.index == index) {
|
||||
if (_controller.index != null && _controller.index == index) {
|
||||
colorAnimation ??= ColorTween(
|
||||
begin: theme.colorScheme.onInverseSurface,
|
||||
end: theme.colorScheme.surface,
|
||||
).animate(_videoReplyReplyController.controller!);
|
||||
).animate(_controller.controller!);
|
||||
return AnimatedBuilder(
|
||||
animation: colorAnimation!,
|
||||
builder: (context, child) {
|
||||
@@ -413,7 +354,7 @@ class _VideoReplyReplyPanelState
|
||||
),
|
||||
Error(:var errMsg) => errorWidget(
|
||||
errMsg: errMsg,
|
||||
onReload: _videoReplyReplyController.onReload,
|
||||
onReload: _controller.onReload,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -422,11 +363,12 @@ class _VideoReplyReplyPanelState
|
||||
return ReplyItemGrpc(
|
||||
replyItem: replyItem,
|
||||
replyLevel: widget.isDialogue ? 3 : 2,
|
||||
onReply: (replyItem) => _onReply(replyItem, index),
|
||||
onReply: (replyItem) =>
|
||||
_controller.onReply(context, replyItem: replyItem, index: index),
|
||||
onDelete: (item, subIndex) {
|
||||
_videoReplyReplyController.onRemove(index, item, null);
|
||||
_controller.onRemove(index, item, null);
|
||||
},
|
||||
upMid: _videoReplyReplyController.upMid,
|
||||
upMid: _controller.upMid,
|
||||
showDialogue: () => _key.currentState?.showBottomSheet(
|
||||
backgroundColor: Colors.transparent,
|
||||
(context) => VideoReplyReplyPanel(
|
||||
@@ -441,8 +383,7 @@ class _VideoReplyReplyPanelState
|
||||
onViewImage: widget.onViewImage,
|
||||
onDismissed: widget.onDismissed,
|
||||
callback: _getImageCallback,
|
||||
onCheckReply: (item) => _videoReplyReplyController
|
||||
.onCheckReply(context, item, isManual: true),
|
||||
onCheckReply: (item) => _controller.onCheckReply(item, isManual: true),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -377,7 +377,7 @@ class _SendDanmakuPanelState extends CommonPublishPageState<SendDanmakuPanel> {
|
||||
} else if (isEmpty && enablePublish.value) {
|
||||
enablePublish.value = false;
|
||||
}
|
||||
widget.onSave?.call(value);
|
||||
widget.onSave?.call((text: value, mentions: null));
|
||||
},
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (value) {
|
||||
|
||||
Reference in New Issue
Block a user