diff --git a/lib/common/widgets/custom_tooltip.dart b/lib/common/widgets/custom_tooltip.dart new file mode 100644 index 00000000..cf374cf3 --- /dev/null +++ b/lib/common/widgets/custom_tooltip.dart @@ -0,0 +1,378 @@ +import 'dart:math' as math; +import 'dart:ui' show clampDouble; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +enum TooltipType { top, right } + +class CustomTooltip extends StatefulWidget { + const CustomTooltip({ + super.key, + this.type = TooltipType.top, + required this.overlayWidget, + required this.child, + this.indicator, + }); + + final TooltipType type; + final Widget child; + final Widget Function() overlayWidget; + final Widget Function()? indicator; + + static final List _openedTooltips = + []; + + static bool dismissAllToolTips() { + if (_openedTooltips.isNotEmpty) { + final List openedTooltips = _openedTooltips.toList(); + for (final CustomTooltipState state in openedTooltips) { + assert(state.mounted); + state._scheduleDismissTooltip(); + } + return true; + } + return false; + } + + @override + State createState() => CustomTooltipState(); +} + +class CustomTooltipState extends State + with SingleTickerProviderStateMixin { + static const Duration _fadeInDuration = Duration(milliseconds: 150); + static const Duration _fadeOutDuration = Duration(milliseconds: 75); + + final OverlayPortalController _overlayController = OverlayPortalController(); + + AnimationController? _backingController; + AnimationController get _controller { + return _backingController ??= AnimationController( + duration: _fadeInDuration, + reverseDuration: _fadeOutDuration, + vsync: this, + )..addStatusListener(_handleStatusChanged); + } + + CurvedAnimation? _backingOverlayAnimation; + CurvedAnimation get _overlayAnimation { + return _backingOverlayAnimation ??= CurvedAnimation( + parent: _controller, + curve: Curves.fastOutSlowIn, + ); + } + + LongPressGestureRecognizer? _longPressRecognizer; + + AnimationStatus _animationStatus = AnimationStatus.dismissed; + void _handleStatusChanged(AnimationStatus status) { + assert(mounted); + switch ((_animationStatus.isDismissed, status.isDismissed)) { + case (false, true): + CustomTooltip._openedTooltips.remove(this); + _overlayController.hide(); + case (true, false): + _overlayController.show(); + CustomTooltip._openedTooltips.add(this); + case (true, true) || (false, false): + break; + } + _animationStatus = status; + } + + void _scheduleShowTooltip() { + _controller.forward(); + } + + void _scheduleDismissTooltip() { + _controller.reverse(); + } + + void _handlePointerDown(PointerDownEvent event) { + assert(mounted); + const Set triggerModeDeviceKinds = { + PointerDeviceKind.invertedStylus, + PointerDeviceKind.stylus, + PointerDeviceKind.touch, + PointerDeviceKind.unknown, + PointerDeviceKind.trackpad, + }; + _longPressRecognizer ??= LongPressGestureRecognizer( + debugOwner: this, + supportedDevices: triggerModeDeviceKinds, + ); + _longPressRecognizer! + ..onLongPress = _scheduleShowTooltip + ..addPointer(event); + } + + Widget _buildCustomTooltipOverlay(BuildContext context) { + final OverlayState overlayState = + Overlay.of(context, debugRequiredFor: widget); + final RenderBox box = this.context.findRenderObject()! as RenderBox; + final Offset target = box.localToGlobal( + box.size.center(Offset.zero), + ancestor: overlayState.context.findRenderObject(), + ); + + final _CustomTooltipOverlay overlayChild = _CustomTooltipOverlay( + verticalOffset: box.size.height / 2, + horizontslOffset: box.size.width / 2, + type: widget.type, + animation: _overlayAnimation, + target: target, + onDismiss: _scheduleDismissTooltip, + overlayWidget: widget.overlayWidget, + indicator: widget.indicator, + ); + + return SelectionContainer.maybeOf(context) == null + ? overlayChild + : SelectionContainer.disabled(child: overlayChild); + } + + @protected + @override + void dispose() { + CustomTooltip._openedTooltips.remove(this); + _longPressRecognizer?.onLongPressCancel = null; + _longPressRecognizer?.dispose(); + _backingController?.dispose(); + _backingOverlayAnimation?.dispose(); + super.dispose(); + } + + @protected + @override + Widget build(BuildContext context) { + Widget result = Listener( + onPointerDown: _handlePointerDown, + behavior: HitTestBehavior.opaque, + child: widget.child, + ); + return OverlayPortal( + controller: _overlayController, + overlayChildBuilder: _buildCustomTooltipOverlay, + child: result, + ); + } +} + +class _CustomTooltipOverlay extends StatelessWidget { + const _CustomTooltipOverlay({ + required this.verticalOffset, + required this.horizontslOffset, + required this.type, + required this.animation, + required this.target, + required this.onDismiss, + required this.overlayWidget, + this.indicator, + }); + + final double verticalOffset; + final double horizontslOffset; + final TooltipType type; + final Animation animation; + final Offset target; + final VoidCallback onDismiss; + final Widget Function() overlayWidget; + final Widget Function()? indicator; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onDismiss, + child: CustomMultiChildLayout( + delegate: _CustomMultiTooltipPositionDelegate( + type: type, + target: target, + verticalOffset: verticalOffset, + horizontslOffset: horizontslOffset, + preferBelow: false, + ), + children: [ + LayoutId( + id: 'overlay', + child: overlayWidget(), + ), + if (indicator != null) + LayoutId( + id: 'indicator', + child: indicator!(), + ), + ], + ), + ); + } +} + +class _CustomMultiTooltipPositionDelegate extends MultiChildLayoutDelegate { + _CustomMultiTooltipPositionDelegate({ + required this.type, + required this.target, + required this.verticalOffset, + required this.horizontslOffset, + required this.preferBelow, + }); + + final TooltipType type; + + final Offset target; + + final double verticalOffset; + + final double horizontslOffset; + + final bool preferBelow; + + @override + void performLayout(Size size) { + switch (type) { + case TooltipType.top: + Size? indicatorSize; + if (hasChild('indicator')) { + indicatorSize = layoutChild('indicator', BoxConstraints.loose(size)); + } + + if (hasChild('overlay')) { + final overlaySize = + layoutChild('overlay', BoxConstraints.loose(size)); + Offset offset = positionDependentBox( + type: type, + size: size, + childSize: overlaySize, + target: target, + verticalOffset: verticalOffset, + horizontslOffset: horizontslOffset, + preferBelow: preferBelow, + ); + if (indicatorSize != null) { + offset = Offset(offset.dx, offset.dy - indicatorSize.height + 1); + positionChild( + 'indicator', + Offset( + target.dx - indicatorSize.width / 2, + offset.dy + overlaySize.height - 1, + ), + ); + } + positionChild('overlay', offset); + } + case TooltipType.right: + Size? indicatorSize; + if (hasChild('indicator')) { + indicatorSize = layoutChild('indicator', BoxConstraints.loose(size)); + } + + if (hasChild('overlay')) { + final overlaySize = + layoutChild('overlay', BoxConstraints.loose(size)); + Offset offset = positionDependentBox( + type: type, + size: size, + childSize: overlaySize, + target: target, + verticalOffset: verticalOffset, + horizontslOffset: horizontslOffset, + preferBelow: preferBelow, + ); + if (indicatorSize != null) { + offset = Offset(offset.dx + indicatorSize.height - 1, offset.dy); + positionChild( + 'indicator', + Offset( + offset.dx - indicatorSize.width + 1, + target.dy - indicatorSize.height / 2, + ), + ); + } + positionChild('overlay', offset); + } + } + } + + @override + bool shouldRelayout(_CustomMultiTooltipPositionDelegate oldDelegate) { + return target != oldDelegate.target || + verticalOffset != oldDelegate.verticalOffset || + preferBelow != oldDelegate.preferBelow; + } +} + +class TrianglePainter extends CustomPainter { + TrianglePainter(this.color, {this.type = TooltipType.top}); + final TooltipType type; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + Path path; + switch (type) { + case TooltipType.top: + path = Path() + ..moveTo(0, 0) + ..lineTo(size.width, 0) + ..lineTo(size.width / 2, size.height) + ..close(); + case TooltipType.right: + path = Path() + ..moveTo(0, size.height / 2) + ..lineTo(size.width, 0) + ..lineTo(size.width, size.height) + ..close(); + } + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(TrianglePainter oldDelegate) => color != oldDelegate.color; +} + +Offset positionDependentBox({ + required TooltipType type, + required Size size, + required Size childSize, + required Offset target, + required bool preferBelow, + double verticalOffset = 0.0, + double horizontslOffset = 0.0, + double margin = 10.0, +}) { + switch (type) { + case TooltipType.top: + // VERTICAL DIRECTION + final bool fitsBelow = + target.dy + verticalOffset + childSize.height <= size.height - margin; + final bool fitsAbove = + target.dy - verticalOffset - childSize.height >= margin; + final bool tooltipBelow = + fitsAbove == fitsBelow ? preferBelow : fitsBelow; + final double y; + if (tooltipBelow) { + y = math.min(target.dy + verticalOffset, size.height - margin); + } else { + y = math.max(target.dy - verticalOffset - childSize.height, margin); + } // HORIZONTAL DIRECTION + final double flexibleSpace = size.width - childSize.width; + final double x = flexibleSpace <= 2 * margin + // If there's not enough horizontal space for margin + child, center the + // child. + ? flexibleSpace / 2.0 + : clampDouble( + target.dx - childSize.width / 2, margin, flexibleSpace - margin); + return Offset(x, y); + case TooltipType.right: + final double dy = math.max(margin, target.dy - childSize.height / 2); + final double dx = math.min( + target.dx + horizontslOffset, size.width - childSize.width - margin); + return Offset(dx, dy); + } +} diff --git a/lib/models_new/emote/meta.dart b/lib/models_new/emote/meta.dart index 37a6d019..8d76b514 100644 --- a/lib/models_new/emote/meta.dart +++ b/lib/models_new/emote/meta.dart @@ -1,9 +1,14 @@ class Meta { int? size; + String? alias; - Meta({this.size}); + Meta({ + this.size, + this.alias, + }); factory Meta.fromJson(Map json) => Meta( size: json['size'] as int?, + alias: json['alias'] as String?, ); } diff --git a/lib/pages/emote/view.dart b/lib/pages/emote/view.dart index 26a5bcad..22c257ad 100644 --- a/lib/pages/emote/view.dart +++ b/lib/pages/emote/view.dart @@ -1,4 +1,5 @@ import 'package:PiliPlus/common/widgets/button/icon_button.dart'; +import 'package:PiliPlus/common/widgets/custom_tooltip.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart'; import 'package:PiliPlus/common/widgets/scroll_physics.dart'; @@ -37,6 +38,9 @@ class _EmotePanelState extends State Widget _buildBody( ThemeData theme, LoadingState?> loadingState) { + late final color = Get.currentRoute.startsWith('/whisperDetail') + ? theme.colorScheme.surface + : theme.colorScheme.onInverseSurface; return switch (loadingState) { Loading() => loadingWidget, Success(:var response) => response?.isNotEmpty == true @@ -62,6 +66,60 @@ class _EmotePanelState extends State itemCount: e.emote!.length, itemBuilder: (context, index) { final item = e.emote![index]; + Widget child = Padding( + padding: const EdgeInsets.all(6), + child: isTextEmote + ? Center( + child: Text( + item.text!, + overflow: TextOverflow.clip, + maxLines: 1, + ), + ) + : NetworkImgLayer( + src: item.url!, + width: size, + height: size, + type: ImageType.emote, + boxFit: BoxFit.contain, + ), + ); + if (!isTextEmote) { + child = CustomTooltip( + indicator: () => CustomPaint( + size: const Size(14, 8), + painter: TrianglePainter(color), + ), + overlayWidget: () => Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.all( + Radius.circular(8)), + ), + child: Column( + spacing: 4, + mainAxisSize: MainAxisSize.min, + children: [ + NetworkImgLayer( + src: item.url!, + width: 65, + height: 65, + type: ImageType.emote, + boxFit: BoxFit.contain, + ), + Text( + item.meta?.alias ?? + item.text!.substring( + 1, item.text!.length - 1), + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + child: child, + ); + } return Material( type: MaterialType.transparency, child: InkWell( @@ -75,25 +133,7 @@ class _EmotePanelState extends State ? 24 : 42, null), - child: Padding( - padding: const EdgeInsets.all(6), - child: isTextEmote - ? Center( - child: Text( - item.text!, - overflow: TextOverflow.clip, - maxLines: 1, - ), - ) - : NetworkImgLayer( - src: item.url!, - width: size, - height: size, - semanticsLabel: item.text!, - type: ImageType.emote, - boxFit: BoxFit.contain, - ), - ), + child: child, ), ); }, diff --git a/lib/pages/live_emote/view.dart b/lib/pages/live_emote/view.dart index 45396ab7..700d6606 100644 --- a/lib/pages/live_emote/view.dart +++ b/lib/pages/live_emote/view.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:PiliPlus/common/widgets/custom_tooltip.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart'; import 'package:PiliPlus/common/widgets/scroll_physics.dart'; @@ -44,6 +45,7 @@ class _LiveEmotePanelState extends State } Widget _buildBody(LoadingState?> loadingState) { + late final color = Theme.of(context).colorScheme.onInverseSurface; return switch (loadingState) { Loading() => loadingWidget, Success(:var response) => response?.isNotEmpty == true @@ -88,15 +90,49 @@ class _LiveEmotePanelState extends State widget.onSendEmoticonUnique(e); } }, - child: Padding( - padding: const EdgeInsets.all(6), - child: NetworkImgLayer( - boxFit: BoxFit.contain, - src: e.url!, - width: width, - height: height, - type: ImageType.emote, - quality: item.pkgType == 3 ? null : 80, + child: CustomTooltip( + indicator: () => CustomPaint( + size: const Size(14, 8), + painter: TrianglePainter(color), + ), + overlayWidget: () => Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.all( + Radius.circular(8)), + ), + child: Column( + spacing: 4, + mainAxisSize: MainAxisSize.min, + children: [ + NetworkImgLayer( + src: e.url!, + width: 65, + height: 65, + type: ImageType.emote, + boxFit: BoxFit.contain, + ), + Text( + e.emoji!.startsWith('[') + ? e.emoji!.substring( + 1, e.emoji!.length - 1) + : e.emoji!, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + child: Padding( + padding: const EdgeInsets.all(6), + child: NetworkImgLayer( + boxFit: BoxFit.contain, + src: e.url!, + width: width, + height: height, + type: ImageType.emote, + quality: item.pkgType == 3 ? null : 80, + ), ), ), ),