feat: at user

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-06-24 21:01:30 +08:00
parent fcf758e290
commit 7be3774675
35 changed files with 14468 additions and 207 deletions

View File

@@ -47,6 +47,7 @@
## feat ## feat
- [x] 动态/评论@用户
- [x] 修改消息设置 - [x] 修改消息设置
- [x] 修改聊天设置 - [x] 修改聊天设置
- [x] 展示折叠消息 - [x] 展示折叠消息

View File

@@ -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,
);
}
}
}

View File

@@ -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,
);
}
}
}

View File

@@ -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,
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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),
),
);
}

View File

@@ -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,
),
),
);
}
}

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -915,4 +915,6 @@ class Api {
static const String spaceComic = '${HttpString.appBaseUrl}/x/v2/space/comic'; static const String spaceComic = '${HttpString.appBaseUrl}/x/v2/space/comic';
static const String spaceAudio = '/audio/music-service/web/song/upper'; static const String spaceAudio = '/audio/music-service/web/song/upper';
static const String dynMention = '/x/polymer/web-dynamic/v1/mention/search';
} }

View File

@@ -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_info/data.dart';
import 'package:PiliPlus/models_new/article/article_list/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/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_reserve/data.dart';
import 'package:PiliPlus/models_new/dynamic/dyn_topic_feed/topic_card_list.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'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/top_details.dart';
@@ -141,11 +143,12 @@ class DynamicsHttp {
"dyn_req": { "dyn_req": {
"content": { "content": {
"contents": [ "contents": [
{ if (rawText != null)
"raw_text": rawText, {
"type": 1, "raw_text": rawText,
"biz_id": "", "type": 1,
}, "biz_id": "",
},
...?extraContent, ...?extraContent,
], ],
if (title?.isNotEmpty == true) 'title': title, if (title?.isNotEmpty == true) 'title': title,
@@ -488,4 +491,22 @@ class DynamicsHttp {
return Error(res.data['message']); 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']);
}
}
} }

View File

@@ -510,6 +510,7 @@ class VideoHttp {
int? parent, int? parent,
List? pictures, List? pictures,
bool? syncToDynamic, bool? syncToDynamic,
Map? atNameToMid,
}) async { }) async {
if (message == '') { if (message == '') {
return {'status': false, 'msg': '请输入评论内容'}; return {'status': false, 'msg': '请输入评论内容'};
@@ -520,13 +521,16 @@ class VideoHttp {
if (root != null && root != 0) 'root': root, if (root != null && root != 0) 'root': root,
if (parent != null && parent != 0) 'parent': parent, if (parent != null && parent != 0) 'parent': parent,
'message': message, 'message': message,
if (atNameToMid != null)
'at_name_to_mid': jsonEncode(atNameToMid), // {"name":uid}
if (pictures != null) 'pictures': jsonEncode(pictures), if (pictures != null) 'pictures': jsonEncode(pictures),
if (syncToDynamic == true) 'sync_to_dynamic': 1, if (syncToDynamic == true) 'sync_to_dynamic': 1,
'csrf': Accounts.main.csrf, 'csrf': Accounts.main.csrf,
}; };
var res = await Request().post( var res = await Request().post(
Api.replyAdd, Api.replyAdd,
data: FormData.fromMap(data), data: data,
options: Options(contentType: Headers.formUrlEncodedContentType),
); );
log(res.toString()); log(res.toString());
if (res.data['code'] == 0) { if (res.data['code'] == 0) {

View 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(),
);
}

View 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(),
);
}

View 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;
}

View File

@@ -625,7 +625,7 @@ class _ArticlePageState extends State<ArticlePage>
upMid: _articleCtr.upMid, upMid: _articleCtr.upMid,
callback: _getImageCallback, callback: _getImageCallback,
onCheckReply: (item) => onCheckReply: (item) =>
_articleCtr.onCheckReply(context, item, isManual: true), _articleCtr.onCheckReply(item, isManual: true),
onToggleTop: (item) => _articleCtr.onToggleTop( onToggleTop: (item) => _articleCtr.onToggleTop(
item, item,
index, index,

View File

@@ -6,12 +6,15 @@ import 'package:PiliPlus/common/widgets/button/icon_button.dart';
import 'package:PiliPlus/http/msg.dart'; import 'package:PiliPlus/http/msg.dart';
import 'package:PiliPlus/models/common/image_preview_type.dart'; import 'package:PiliPlus/models/common/image_preview_type.dart';
import 'package:PiliPlus/models/common/publish_panel_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/emote/emote.dart';
import 'package:PiliPlus/models_new/live/live_emote/emoticon.dart'; import 'package:PiliPlus/models_new/live/live_emote/emoticon.dart';
import 'package:PiliPlus/models_new/upload_bfs/data.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/extension.dart';
import 'package:PiliPlus/utils/feed_back.dart'; import 'package:PiliPlus/utils/feed_back.dart';
import 'package:chat_bottom_container/chat_bottom_container.dart'; import 'package:chat_bottom_container/chat_bottom_container.dart';
import 'package:collection/collection.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_debounce/easy_throttle.dart'; import 'package:easy_debounce/easy_throttle.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -24,14 +27,16 @@ abstract class CommonPublishPage extends StatefulWidget {
const CommonPublishPage({ const CommonPublishPage({
super.key, super.key,
this.initialValue, this.initialValue,
this.mentions,
this.imageLengthLimit, this.imageLengthLimit,
this.onSave, this.onSave,
this.autofocus = true, this.autofocus = true,
}); });
final String? initialValue; final String? initialValue;
final Set<MentionItem>? mentions;
final int? imageLengthLimit; final int? imageLengthLimit;
final ValueChanged<String>? onSave; final ValueChanged<({String text, Set<MentionItem>? mentions})>? onSave;
final bool autofocus; final bool autofocus;
} }
@@ -49,11 +54,14 @@ abstract class CommonPublishPageState<T extends CommonPublishPage>
late final RxList<String> pathList = <String>[].obs; late final RxList<String> pathList = <String>[].obs;
int get limit => widget.imageLengthLimit ?? 9; int get limit => widget.imageLengthLimit ?? 9;
Set<MentionItem>? mentions;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
mentions = widget.mentions;
if (widget.initialValue?.trim().isNotEmpty == true) { if (widget.initialValue?.trim().isNotEmpty == true) {
enablePublish.value = true; enablePublish.value = true;
} }
@@ -221,7 +229,7 @@ abstract class CommonPublishPageState<T extends CommonPublishPage>
offset: cursorPosition + emote.emoji!.length), offset: cursorPosition + emote.emoji!.length),
); );
} }
widget.onSave?.call(editController.text); widget.onSave?.call((text: editController.text, mentions: mentions));
} }
Widget? get customPanel => null; 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));
}
});
}
} }

View File

@@ -4,6 +4,7 @@ import 'package:PiliPlus/grpc/bilibili/pagination.pb.dart';
import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/http/reply.dart'; import 'package:PiliPlus/http/reply.dart';
import 'package:PiliPlus/models/common/reply/reply_sort_type.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/common/common_list_controller.dart';
import 'package:PiliPlus/pages/video/reply_new/view.dart'; import 'package:PiliPlus/pages/video/reply_new/view.dart';
import 'package:PiliPlus/services/account_service.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<ReplySortType> sortType;
late Rx<Mode> mode; late Rx<Mode> mode;
late final savedReplies = {}; late final savedReplies =
<Object, ({String text, Set<MentionItem>? mentions})?>{};
AccountService accountService = Get.find<AccountService>(); AccountService accountService = Get.find<AccountService>();
@@ -125,16 +127,16 @@ abstract class ReplyController<R> extends CommonListController<R, ReplyInfo> {
.push( .push(
GetDialogRoute( GetDialogRoute(
pageBuilder: (buildContext, animation, secondaryAnimation) { pageBuilder: (buildContext, animation, secondaryAnimation) {
final saved = savedReplies[key];
return ReplyPage( return ReplyPage(
oid: oid ?? replyItem!.oid.toInt(), oid: oid ?? replyItem!.oid.toInt(),
root: oid != null ? 0 : replyItem!.id.toInt(), root: oid != null ? 0 : replyItem!.id.toInt(),
parent: oid != null ? 0 : replyItem!.id.toInt(), parent: oid != null ? 0 : replyItem!.id.toInt(),
replyType: replyItem?.type.toInt() ?? replyType!, replyType: replyItem?.type.toInt() ?? replyType!,
replyItem: replyItem, replyItem: replyItem,
initialValue: savedReplies[key], initialValue: saved?.text,
onSave: (reply) { mentions: saved?.mentions,
savedReplies[key] = reply; onSave: (reply) => savedReplies[key] = reply,
},
hint: hint, hint: hint,
); );
}, },
@@ -157,7 +159,7 @@ abstract class ReplyController<R> extends CommonListController<R, ReplyInfo> {
.then( .then(
(res) { (res) {
if (res != null) { if (res != null) {
savedReplies[key] = null; savedReplies.remove(key);
ReplyInfo replyInfo = RequestUtils.replyCast(res); ReplyInfo replyInfo = RequestUtils.replyCast(res);
if (loadingState.value.isSuccess) { if (loadingState.value.isSuccess) {
List<ReplyInfo>? list = loadingState.value.data; List<ReplyInfo>? list = loadingState.value.data;
@@ -179,8 +181,8 @@ abstract class ReplyController<R> extends CommonListController<R, ReplyInfo> {
count.value += 1; count.value += 1;
// check reply // check reply
if (enableCommAntifraud && context.mounted) { if (enableCommAntifraud) {
onCheckReply(context, replyInfo, isManual: false); onCheckReply(replyInfo, isManual: false);
} }
} }
}, },
@@ -200,8 +202,7 @@ abstract class ReplyController<R> extends CommonListController<R, ReplyInfo> {
loadingState.refresh(); loadingState.refresh();
} }
void onCheckReply(BuildContext context, ReplyInfo replyInfo, void onCheckReply(ReplyInfo replyInfo, {required bool isManual}) {
{required bool isManual}) {
ReplyUtils.onCheckReply( ReplyUtils.onCheckReply(
replyInfo: replyInfo, replyInfo: replyInfo,
biliSendCommAntifraud: _biliSendCommAntifraud, biliSendCommAntifraud: _biliSendCommAntifraud,

View File

@@ -1,19 +1,18 @@
import 'dart:math';
import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/button/icon_button.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/button/toolbar_icon_button.dart';
import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/custom_icon.dart';
import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_dyn.dart' import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_dyn.dart'
as dyn_sheet; 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/pair.dart';
import 'package:PiliPlus/common/widgets/text_field/text_field.dart'
as text_field;
import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/http/dynamics.dart';
import 'package:PiliPlus/models/common/publish_panel_type.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart';
import 'package:PiliPlus/models/common/reply/reply_option_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/models_new/dynamic/dyn_topic_top/topic_item.dart';
import 'package:PiliPlus/pages/common/common_publish_page.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/controller.dart';
import 'package:PiliPlus/pages/dynamics_select_topic/view.dart'; import 'package:PiliPlus/pages/dynamics_select_topic/view.dart';
import 'package:PiliPlus/pages/emote/controller.dart'; import 'package:PiliPlus/pages/emote/controller.dart';
@@ -77,11 +76,10 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
@override @override
void dispose() { void dispose() {
_titleEditCtr.dispose(); _titleEditCtr.dispose();
try { Get
Get ..delete<EmotePanelController>()
..delete<EmotePanelController>() ..delete<SelectTopicController>()
..delete<SelectTopicController>(); ..delete<DynMentionController>();
} catch (_) {}
super.dispose(); super.dispose();
} }
@@ -531,17 +529,28 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
Widget get _buildToolbar => Padding( Widget get _buildToolbar => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Obx( child: Row(
() => ToolbarIconButton( spacing: 16,
onPressed: () => updatePanelType( children: [
panelType.value == PanelType.emoji Obx(
? PanelType.keyboard () => ToolbarIconButton(
: PanelType.emoji, onPressed: () => updatePanelType(
panelType.value == PanelType.emoji
? PanelType.keyboard
: PanelType.emoji,
),
icon: const Icon(Icons.emoji_emotions, size: 22),
tooltip: '表情',
selected: panelType.value == PanelType.emoji,
),
), ),
icon: const Icon(Icons.emoji_emotions, size: 22), ToolbarIconButton(
tooltip: '表情', onPressed: () => onMention(true),
selected: panelType.value == PanelType.emoji, icon: const Icon(Icons.alternate_email, size: 22),
), tooltip: '@',
selected: false,
),
],
), ),
); );
@@ -554,12 +563,14 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
} }
}, },
child: Obx( child: Obx(
() => TextField( () => text_field.TextField(
controller: editController, controller: editController,
minLines: 4, minLines: 4,
maxLines: null, maxLines: null,
focusNode: focusNode, focusNode: focusNode,
readOnly: readOnly.value, readOnly: readOnly.value,
onDelAtUser: (name) =>
mentions?.removeWhere((e) => e.name == name),
onChanged: (value) { onChanged: (value) {
bool isEmpty = value.trim().isEmpty && pathList.isEmpty; bool isEmpty = value.trim().isEmpty && pathList.isEmpty;
if (!isEmpty && !enablePublish.value) { if (!isEmpty && !enablePublish.value) {
@@ -578,6 +589,7 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
inputFormatters: [LengthLimitingTextInputFormatter(1000)], inputFormatters: [LengthLimitingTextInputFormatter(1000)],
onMention: onMention,
), ),
), ),
), ),
@@ -590,9 +602,11 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
Future<void> onCustomPublish( Future<void> onCustomPublish(
{required String message, List? pictures}) async { {required String message, List? pictures}) async {
SmartDialog.showLoading(msg: '正在发布'); SmartDialog.showLoading(msg: '正在发布');
List<Map<String, dynamic>>? extraContent = getRichContent();
final hasMention = extraContent != null;
var result = await DynamicsHttp.createDynamic( var result = await DynamicsHttp.createDynamic(
mid: Accounts.main.mid, mid: Accounts.main.mid,
rawText: editController.text, rawText: hasMention ? null : editController.text,
pics: pictures, pics: pictures,
publishTime: _publishTime.value != null publishTime: _publishTime.value != null
? _publishTime.value!.millisecondsSinceEpoch ~/ 1000 ? _publishTime.value!.millisecondsSinceEpoch ~/ 1000
@@ -601,6 +615,7 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
privatePub: _isPrivate.value ? 1 : null, privatePub: _isPrivate.value ? 1 : null,
title: _titleEditCtr.text, title: _titleEditCtr.text,
topic: topic.value, topic: topic.value,
extraContent: extraContent,
); );
SmartDialog.dismiss(); SmartDialog.dismiss();
if (result['status']) { if (result['status']) {
@@ -618,31 +633,16 @@ class _CreateDynPanelState extends CommonPublishPageState<CreateDynPanel> {
} }
} }
double _offset = 0; double _topicOffset = 0;
Future<void> _onSelectTopic() async { void _onSelectTopic() {
TopicItem? res = await showModalBottomSheet( SelectTopicPanel.onSelectTopic(
context: context, context,
useSafeArea: true, offset: _topicOffset,
isScrollControlled: true, callback: (offset) => _topicOffset = offset,
constraints: BoxConstraints( ).then((TopicItem? res) {
maxWidth: min(600, context.mediaQueryShortestSide), if (res != null) {
), topic.value = Pair(first: res.id, second: res.name);
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,
),
),
);
if (res != null) {
topic.value = Pair(first: res.id, second: res.name);
}
} }
} }

View File

@@ -736,7 +736,7 @@ class _DynamicDetailPageState extends State<DynamicDetailPage>
upMid: _controller.upMid, upMid: _controller.upMid,
callback: _getImageCallback, callback: _getImageCallback,
onCheckReply: (item) => onCheckReply: (item) =>
_controller.onCheckReply(context, item, isManual: true), _controller.onCheckReply(item, isManual: true),
onToggleTop: (item) => _controller.onToggleTop( onToggleTop: (item) => _controller.onToggleTop(
item, item,
index, index,

View 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();
}
}

View 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,
);
}

View 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),
),
),
);
}
}

View File

@@ -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' import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_dyn.dart'
show DraggableScrollableSheet; show DraggableScrollableSheet;
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; 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/http/dynamics.dart';
import 'package:PiliPlus/models/common/publish_panel_type.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart';
import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/models/dynamics/result.dart';
import 'package:PiliPlus/pages/common/common_publish_page.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/controller.dart';
import 'package:PiliPlus/pages/emote/view.dart'; import 'package:PiliPlus/pages/emote/view.dart';
import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/accounts.dart';
import 'package:PiliPlus/utils/request_utils.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/services.dart' show LengthLimitingTextInputFormatter;
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@@ -68,9 +70,9 @@ class _RepostPanelState extends CommonPublishPageState<RepostPanel> {
@override @override
void dispose() { void dispose() {
try { Get
Get.delete<EmotePanelController>(); ..delete<EmotePanelController>()
} catch (_) {} ..delete<DynMentionController>();
super.dispose(); super.dispose();
} }
@@ -239,6 +241,7 @@ class _RepostPanelState extends CommonPublishPageState<RepostPanel> {
contentPadding: const EdgeInsets.symmetric(vertical: 10), contentPadding: const EdgeInsets.symmetric(vertical: 10),
), ),
inputFormatters: [LengthLimitingTextInputFormatter(1000)], inputFormatters: [LengthLimitingTextInputFormatter(1000)],
onMention: onMention,
), ),
), ),
), ),
@@ -323,19 +326,30 @@ class _RepostPanelState extends CommonPublishPageState<RepostPanel> {
Widget get _buildToolbar => Padding( Widget get _buildToolbar => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Obx( child: Row(
() => ToolbarIconButton( spacing: 16,
onPressed: () { children: [
updatePanelType( Obx(
panelType.value == PanelType.emoji () => ToolbarIconButton(
? PanelType.keyboard onPressed: () {
: PanelType.emoji, updatePanelType(
); panelType.value == PanelType.emoji
}, ? PanelType.keyboard
icon: const Icon(Icons.emoji_emotions, size: 22), : PanelType.emoji,
tooltip: '表情', );
selected: panelType.value == PanelType.emoji, },
), icon: const Icon(Icons.emoji_emotions, size: 22),
tooltip: '表情',
selected: panelType.value == PanelType.emoji,
),
),
ToolbarIconButton(
onPressed: () => onMention(true),
icon: const Icon(Icons.alternate_email, size: 22),
tooltip: '@',
selected: false,
),
],
), ),
); );
@@ -403,15 +417,23 @@ class _RepostPanelState extends CommonPublishPageState<RepostPanel> {
@override @override
Future<void> onCustomPublish( Future<void> onCustomPublish(
{required String message, List? pictures}) async { {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( var result = await DynamicsHttp.createDynamic(
mid: Accounts.main.mid, mid: Accounts.main.mid,
dynIdStr: widget.item?.idStr ?? widget.dynIdStr, dynIdStr: widget.item?.idStr ?? widget.dynIdStr,
rid: widget.rid, rid: widget.rid,
dynType: widget.dynType, dynType: widget.dynType,
rawText: editController.text, rawText: hasMention ? null : editController.text,
extraContent: extraContent: content ?? repostContent,
widget.item?.orig != null ? extraContent(widget.item!) : null,
); );
SmartDialog.dismiss();
if (result['status']) { if (result['status']) {
Get.back(); Get.back();
SmartDialog.showToast('转发成功'); SmartDialog.showToast('转发成功');

View File

@@ -1,5 +1,8 @@
import 'dart:async'; 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/common/widgets/loading_widget/loading_widget.dart';
import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/topic_item.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 ScrollController? scrollController;
final ValueChanged<double>? callback; 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 @override
State<SelectTopicPanel> createState() => _SelectTopicPanelState(); State<SelectTopicPanel> createState() => _SelectTopicPanelState();
} }

View File

@@ -607,7 +607,7 @@ class _LiveRoomPageState extends State<LiveRoomPage>
fromEmote: fromEmote, fromEmote: fromEmote,
liveRoomController: _liveRoomController, liveRoomController: _liveRoomController,
initialValue: _liveRoomController.savedDanmaku, initialValue: _liveRoomController.savedDanmaku,
onSave: (msg) => _liveRoomController.savedDanmaku = msg, onSave: (msg) => _liveRoomController.savedDanmaku = msg.text,
); );
}, },
transitionDuration: const Duration(milliseconds: 500), transitionDuration: const Duration(milliseconds: 500),

View File

@@ -217,8 +217,8 @@ class _MatchInfoPageState extends State<MatchInfoPage> {
onDelete: (item, subIndex) => onDelete: (item, subIndex) =>
_controller.onRemove(index, item, subIndex), _controller.onRemove(index, item, subIndex),
upMid: _controller.upMid, upMid: _controller.upMid,
onCheckReply: (item) => _controller onCheckReply: (item) =>
.onCheckReply(context, item, isManual: true), _controller.onCheckReply(item, isManual: true),
onToggleTop: (item) => _controller.onToggleTop( onToggleTop: (item) => _controller.onToggleTop(
item, item,
index, index,

View File

@@ -945,7 +945,7 @@ class VideoDetailController extends GetxController
bvid: bvid, bvid: bvid,
progress: plPlayerController.position.value.inMilliseconds, progress: plPlayerController.position.value.inMilliseconds,
initialValue: savedDanmaku, initialValue: savedDanmaku,
onSave: (danmaku) => savedDanmaku = danmaku, onSave: (danmaku) => savedDanmaku = danmaku.text,
callback: (danmakuModel) { callback: (danmakuModel) {
savedDanmaku = null; savedDanmaku = null;
plPlayerController.danmakuController?.addDanmaku(danmakuModel); plPlayerController.danmakuController?.addDanmaku(danmakuModel);

View File

@@ -229,7 +229,7 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
onDismissed: widget.onDismissed, onDismissed: widget.onDismissed,
callback: widget.callback, callback: widget.callback,
onCheckReply: (item) => _videoReplyController onCheckReply: (item) => _videoReplyController
.onCheckReply(context, item, isManual: true), .onCheckReply(item, isManual: true),
onToggleTop: (item) => _videoReplyController.onToggleTop( onToggleTop: (item) => _videoReplyController.onToggleTop(
item, item,
index, index,

View File

@@ -1,15 +1,17 @@
import 'dart:async'; import 'dart:async';
import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart'; 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' import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart'
show ReplyInfo; show ReplyInfo;
import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/http/video.dart';
import 'package:PiliPlus/main.dart'; import 'package:PiliPlus/main.dart';
import 'package:PiliPlus/models/common/publish_panel_type.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart';
import 'package:PiliPlus/pages/common/common_publish_page.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/pages/emote/view.dart';
import 'package:PiliPlus/utils/storage_pref.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:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@@ -24,6 +26,7 @@ class ReplyPage extends CommonPublishPage {
const ReplyPage({ const ReplyPage({
super.key, super.key,
super.initialValue, super.initialValue,
super.mentions,
super.imageLengthLimit, super.imageLengthLimit,
super.onSave, super.onSave,
required this.oid, required this.oid,
@@ -66,6 +69,12 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
), ),
); );
@override
void dispose() {
Get.delete<DynMentionController>();
super.dispose();
}
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
@@ -141,7 +150,7 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
} else if (isEmpty && enablePublish.value) { } else if (isEmpty && enablePublish.value) {
enablePublish.value = false; enablePublish.value = false;
} }
widget.onSave?.call(value); widget.onSave?.call((text: value, mentions: mentions));
}, },
focusNode: focusNode, focusNode: focusNode,
decoration: InputDecoration( decoration: InputDecoration(
@@ -150,6 +159,7 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
hintStyle: const TextStyle(fontSize: 14), hintStyle: const TextStyle(fontSize: 14),
), ),
style: themeData.textTheme.bodyLarge, style: themeData.textTheme.bodyLarge,
onMention: onMention,
), ),
), ),
), ),
@@ -163,7 +173,6 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
height: 52, height: 52,
padding: const EdgeInsets.only(left: 12, right: 12), padding: const EdgeInsets.only(left: 12, right: 12),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Obx( Obx(
() => ToolbarIconButton( () => ToolbarIconButton(
@@ -177,7 +186,7 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
selected: panelType.value == PanelType.keyboard, selected: panelType.value == PanelType.keyboard,
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 8),
Obx( Obx(
() => ToolbarIconButton( () => ToolbarIconButton(
tooltip: '表情', tooltip: '表情',
@@ -191,7 +200,7 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
), ),
), ),
if (widget.root == 0) ...[ if (widget.root == 0) ...[
const SizedBox(width: 10), const SizedBox(width: 8),
ToolbarIconButton( ToolbarIconButton(
tooltip: '图片', tooltip: '图片',
selected: false, selected: false,
@@ -199,32 +208,56 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
onPressed: onPickImage, onPressed: onPickImage,
), ),
], ],
const Spacer(), const SizedBox(width: 8),
Obx( ToolbarIconButton(
() => TextButton.icon( onPressed: () => onMention(true),
style: TextButton.styleFrom( icon: const Icon(Icons.alternate_email, size: 22),
padding: tooltip: '@',
const EdgeInsets.symmetric(horizontal: 15, vertical: 13), selected: false,
visualDensity: VisualDensity.compact, ),
foregroundColor: _syncToDynamic.value Expanded(
? themeData.colorScheme.secondary child: Center(
: themeData.colorScheme.outline, child: Obx(
() => TextButton(
style: TextButton.styleFrom(
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,
child: Row(
spacing: 4,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_syncToDynamic.value
? Icons.check_box
: Icons.check_box_outline_blank,
size: 22,
),
const Flexible(
child: Text(
'转到动态',
maxLines: 1,
style: TextStyle(height: 1),
strutStyle: StrutStyle(leading: 0, height: 1),
),
),
],
),
),
), ),
onPressed: () => _syncToDynamic.value = !_syncToDynamic.value,
icon: Icon(
_syncToDynamic.value
? Icons.check_box
: Icons.check_box_outline_blank,
size: 22,
),
label: const Text('转发至动态'),
), ),
), ),
const Spacer(),
Obx( Obx(
() => FilledButton.tonal( () => FilledButton.tonal(
onPressed: enablePublish.value ? onPublish : null, onPressed: enablePublish.value ? onPublish : null,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 10), const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@@ -249,6 +282,9 @@ class _ReplyPageState extends CommonPublishPageState<ReplyPage> {
message: widget.replyItem != null && widget.replyItem!.root != 0 message: widget.replyItem != null && widget.replyItem!.root != 0
? ' 回复 @${widget.replyItem!.member.name} : $message' ? ' 回复 @${widget.replyItem!.member.name} : $message'
: message, : message,
atNameToMid: mentions?.isNotEmpty == true
? {for (var e in mentions!) e.name: e.uid}
: null,
pictures: pictures, pictures: pictures,
syncToDynamic: _syncToDynamic.value, syncToDynamic: _syncToDynamic.value,
); );

View File

@@ -3,10 +3,13 @@ import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart'
import 'package:PiliPlus/grpc/reply.dart'; import 'package:PiliPlus/grpc/reply.dart';
import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/pages/common/reply_controller.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/id_utils.dart';
import 'package:PiliPlus/utils/request_utils.dart';
import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/storage_pref.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class VideoReplyReplyController extends ReplyController class VideoReplyReplyController extends ReplyController
@@ -129,6 +132,67 @@ class VideoReplyReplyController extends ReplyController
onReload(); 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 @override
void onClose() { void onClose() {
controller?.dispose(); controller?.dispose();

View File

@@ -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/http/loading_state.dart';
import 'package:PiliPlus/pages/common/common_slide_page.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/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/pages/video/reply_reply/controller.dart';
import 'package:PiliPlus/utils/num_util.dart'; import 'package:PiliPlus/utils/num_util.dart';
import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/request_utils.dart';
import 'package:PiliPlus/utils/utils.dart'; import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class VideoReplyReplyPanel extends CommonSlidePage { class VideoReplyReplyPanel extends CommonSlidePage {
@@ -51,27 +48,25 @@ class VideoReplyReplyPanel extends CommonSlidePage {
class _VideoReplyReplyPanelState class _VideoReplyReplyPanelState
extends CommonSlidePageState<VideoReplyReplyPanel> { extends CommonSlidePageState<VideoReplyReplyPanel> {
late VideoReplyReplyController _videoReplyReplyController; late VideoReplyReplyController _controller;
late final _savedReplies = <int, String>{};
late final itemPositionsListener = ItemPositionsListener.create(); late final itemPositionsListener = ItemPositionsListener.create();
late final _key = GlobalKey<ScaffoldState>(); late final _key = GlobalKey<ScaffoldState>();
late final _listKey = GlobalKey(); late final _listKey = GlobalKey();
late final _tag = late final _tag =
Utils.makeHeroTag('${widget.rpid}${widget.dialog}${widget.isDialogue}'); Utils.makeHeroTag('${widget.rpid}${widget.dialog}${widget.isDialogue}');
ReplyInfo? get firstFloor => ReplyInfo? get firstFloor => widget.firstFloor ?? _controller.firstFloor;
widget.firstFloor ?? _videoReplyReplyController.firstFloor;
bool get _horizontalPreview => bool get _horizontalPreview =>
context.orientation == Orientation.landscape && context.orientation == Orientation.landscape &&
_videoReplyReplyController.horizontalPreview; _controller.horizontalPreview;
Animation<Color?>? colorAnimation; Animation<Color?>? colorAnimation;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_videoReplyReplyController = Get.put( _controller = Get.put(
VideoReplyReplyController( VideoReplyReplyController(
hasRoot: widget.firstFloor != null, hasRoot: widget.firstFloor != null,
id: widget.id, id: widget.id,
@@ -159,7 +154,7 @@ class _VideoReplyReplyPanelState
Widget buildList(ThemeData theme) { Widget buildList(ThemeData theme) {
return ClipRect( return ClipRect(
child: refreshIndicator( child: refreshIndicator(
onRefresh: _videoReplyReplyController.onRefresh, onRefresh: _controller.onRefresh,
child: Obx( child: Obx(
() => Stack( () => Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
@@ -167,27 +162,27 @@ class _VideoReplyReplyPanelState
ScrollablePositionedList.builder( ScrollablePositionedList.builder(
key: _listKey, key: _listKey,
itemPositionsListener: itemPositionsListener, itemPositionsListener: itemPositionsListener,
itemCount: itemCount: _itemCount(_controller.loadingState.value),
_itemCount(_videoReplyReplyController.loadingState.value), itemScrollController: _controller.itemScrollCtr,
itemScrollController: _videoReplyReplyController.itemScrollCtr,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (widget.isDialogue) { if (widget.isDialogue) {
return _buildBody(theme, return _buildBody(
_videoReplyReplyController.loadingState.value, index); theme, _controller.loadingState.value, index);
} else if (firstFloor != null) { } else if (firstFloor != null) {
if (index == 0) { if (index == 0) {
return ReplyItemGrpc( return ReplyItemGrpc(
replyItem: firstFloor!, replyItem: firstFloor!,
replyLevel: 2, replyLevel: 2,
needDivider: false, needDivider: false,
onReply: (replyItem) => _onReply(replyItem, -1), onReply: (replyItem) => _controller.onReply(context,
upMid: _videoReplyReplyController.upMid, replyItem: replyItem, index: -1),
upMid: _controller.upMid,
onViewImage: widget.onViewImage, onViewImage: widget.onViewImage,
onDismissed: widget.onDismissed, onDismissed: widget.onDismissed,
callback: _getImageCallback, callback: _getImageCallback,
onCheckReply: (item) => _videoReplyReplyController onCheckReply: (item) =>
.onCheckReply(context, item, isManual: true), _controller.onCheckReply(item, isManual: true),
); );
} else if (index == 1) { } else if (index == 1) {
return Divider( return Divider(
@@ -200,7 +195,7 @@ class _VideoReplyReplyPanelState
} else { } else {
return _buildBody( return _buildBody(
theme, theme,
_videoReplyReplyController.loadingState.value, _controller.loadingState.value,
index - 3, index - 3,
); );
} }
@@ -210,7 +205,7 @@ class _VideoReplyReplyPanelState
} else { } else {
return _buildBody( return _buildBody(
theme, theme,
_videoReplyReplyController.loadingState.value, _controller.loadingState.value,
index - 1, index - 1,
); );
} }
@@ -218,7 +213,7 @@ class _VideoReplyReplyPanelState
}, },
), ),
if (!widget.isDialogue && if (!widget.isDialogue &&
_videoReplyReplyController.loadingState.value.isSuccess) _controller.loadingState.value.isSuccess)
_header(theme), _header(theme),
], ],
), ),
@@ -243,9 +238,9 @@ class _VideoReplyReplyPanelState
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Obx( Obx(
() => _videoReplyReplyController.count.value != -1 () => _controller.count.value != -1
? Text( ? Text(
'相关回复共${NumUtil.numFormat(_videoReplyReplyController.count.value)}', '相关回复共${NumUtil.numFormat(_controller.count.value)}',
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
@@ -253,7 +248,7 @@ class _VideoReplyReplyPanelState
SizedBox( SizedBox(
height: 35, height: 35,
child: TextButton.icon( child: TextButton.icon(
onPressed: () => _videoReplyReplyController.queryBySort(), onPressed: () => _controller.queryBySort(),
icon: Icon( icon: Icon(
Icons.sort, Icons.sort,
size: 16, size: 16,
@@ -261,7 +256,7 @@ class _VideoReplyReplyPanelState
), ),
label: Obx( label: Obx(
() => Text( () => Text(
_videoReplyReplyController.mode.value == Mode.MAIN_LIST_HOT _controller.mode.value == Mode.MAIN_LIST_HOT
? '按热度' ? '按热度'
: '按时间', : '按时间',
style: TextStyle( style: TextStyle(
@@ -306,59 +301,6 @@ class _VideoReplyReplyPanelState
} }
: null; : 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( Widget _buildBody(
ThemeData theme, LoadingState<List<ReplyInfo>?> loadingState, int index) { ThemeData theme, LoadingState<List<ReplyInfo>?> loadingState, int index) {
return switch (loadingState) { return switch (loadingState) {
@@ -375,14 +317,14 @@ class _VideoReplyReplyPanelState
Success(:var response) => Builder( Success(:var response) => Builder(
builder: (context) { builder: (context) {
if (index == response!.length) { if (index == response!.length) {
_videoReplyReplyController.onLoadMore(); _controller.onLoadMore();
return Container( return Container(
alignment: Alignment.center, alignment: Alignment.center,
margin: EdgeInsets.only( margin: EdgeInsets.only(
bottom: MediaQuery.paddingOf(context).bottom), bottom: MediaQuery.paddingOf(context).bottom),
height: 125, height: 125,
child: Text( child: Text(
_videoReplyReplyController.isEnd ? '没有更多了' : '加载中...', _controller.isEnd ? '没有更多了' : '加载中...',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: theme.colorScheme.outline, color: theme.colorScheme.outline,
@@ -390,12 +332,11 @@ class _VideoReplyReplyPanelState
), ),
); );
} else { } else {
if (_videoReplyReplyController.index != null && if (_controller.index != null && _controller.index == index) {
_videoReplyReplyController.index == index) {
colorAnimation ??= ColorTween( colorAnimation ??= ColorTween(
begin: theme.colorScheme.onInverseSurface, begin: theme.colorScheme.onInverseSurface,
end: theme.colorScheme.surface, end: theme.colorScheme.surface,
).animate(_videoReplyReplyController.controller!); ).animate(_controller.controller!);
return AnimatedBuilder( return AnimatedBuilder(
animation: colorAnimation!, animation: colorAnimation!,
builder: (context, child) { builder: (context, child) {
@@ -413,7 +354,7 @@ class _VideoReplyReplyPanelState
), ),
Error(:var errMsg) => errorWidget( Error(:var errMsg) => errorWidget(
errMsg: errMsg, errMsg: errMsg,
onReload: _videoReplyReplyController.onReload, onReload: _controller.onReload,
), ),
}; };
} }
@@ -422,11 +363,12 @@ class _VideoReplyReplyPanelState
return ReplyItemGrpc( return ReplyItemGrpc(
replyItem: replyItem, replyItem: replyItem,
replyLevel: widget.isDialogue ? 3 : 2, replyLevel: widget.isDialogue ? 3 : 2,
onReply: (replyItem) => _onReply(replyItem, index), onReply: (replyItem) =>
_controller.onReply(context, replyItem: replyItem, index: index),
onDelete: (item, subIndex) { onDelete: (item, subIndex) {
_videoReplyReplyController.onRemove(index, item, null); _controller.onRemove(index, item, null);
}, },
upMid: _videoReplyReplyController.upMid, upMid: _controller.upMid,
showDialogue: () => _key.currentState?.showBottomSheet( showDialogue: () => _key.currentState?.showBottomSheet(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
(context) => VideoReplyReplyPanel( (context) => VideoReplyReplyPanel(
@@ -441,8 +383,7 @@ class _VideoReplyReplyPanelState
onViewImage: widget.onViewImage, onViewImage: widget.onViewImage,
onDismissed: widget.onDismissed, onDismissed: widget.onDismissed,
callback: _getImageCallback, callback: _getImageCallback,
onCheckReply: (item) => _videoReplyReplyController onCheckReply: (item) => _controller.onCheckReply(item, isManual: true),
.onCheckReply(context, item, isManual: true),
); );
} }

View File

@@ -377,7 +377,7 @@ class _SendDanmakuPanelState extends CommonPublishPageState<SendDanmakuPanel> {
} else if (isEmpty && enablePublish.value) { } else if (isEmpty && enablePublish.value) {
enablePublish.value = false; enablePublish.value = false;
} }
widget.onSave?.call(value); widget.onSave?.call((text: value, mentions: null));
}, },
textInputAction: TextInputAction.send, textInputAction: TextInputAction.send,
onSubmitted: (value) { onSubmitted: (value) {