import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; String _withChinesePunctuation(String value) { const replacements = { ':': ':', ',': ',', ';': ';', '!': '!', '?': '?', '(': '(', ')': ')', '[': '【', ']': '】', '¥': '¥', }; return value.split('').map((char) => replacements[char] ?? char).join(); } class MallEntryPromoTooltip extends StatefulWidget { const MallEntryPromoTooltip({ super.key, required this.visible, required this.title, required this.body, required this.onCardTap, required this.child, }); final bool visible; final String title; final String body; final VoidCallback onCardTap; final Widget child; @override State createState() => _MallEntryPromoTooltipState(); } class _MallEntryPromoTooltipState extends State { static const _cardWidth = 236.0; static const _cardHeight = 115.0; static const _anchorToCardBottom = 54.0; static const _targetCenterToCardRight = 23.0; static const _cardRightInset = 22.0; final _targetKey = GlobalKey(); OverlayEntry? _overlayEntry; bool _routeIsCurrent = true; @override void initState() { super.initState(); _scheduleVisibilitySync(); } @override void didChangeDependencies() { super.didChangeDependencies(); _routeIsCurrent = ModalRoute.of(context)?.isCurrent ?? true; _scheduleVisibilitySync(); } @override void didUpdateWidget(covariant MallEntryPromoTooltip oldWidget) { super.didUpdateWidget(oldWidget); _scheduleVisibilitySync(); } void _scheduleVisibilitySync() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (widget.visible && _routeIsCurrent) { _showOverlay(); } else { _removeOverlay(); } }); } void _showOverlay() { final current = _overlayEntry; if (current != null) { current.markNeedsBuild(); return; } final entry = OverlayEntry(builder: _buildOverlay); _overlayEntry = entry; Overlay.of(context, rootOverlay: true).insert(entry); } void _removeOverlay() { final entry = _overlayEntry; if (entry == null) return; _overlayEntry = null; entry ..remove() ..dispose(); } @override void dispose() { _removeOverlay(); super.dispose(); } @override Widget build(BuildContext context) { return KeyedSubtree(key: _targetKey, child: widget.child); } Widget _buildOverlay(BuildContext overlayContext) { final targetContext = _targetKey.currentContext; final targetBox = targetContext?.findRenderObject(); if (targetContext == null || targetBox is! RenderBox || !targetBox.attached) { return const SizedBox.shrink(); } final overlayBox = Overlay.of( targetContext, rootOverlay: true, ).context.findRenderObject(); if (overlayBox is! RenderBox || !overlayBox.attached) { return const SizedBox.shrink(); } final targetCenter = targetBox.localToGlobal( targetBox.size.center(Offset.zero), ancestor: overlayBox, ); final title = _withChinesePunctuation(widget.title.trim()); final body = _withChinesePunctuation(widget.body.trim()); return Positioned.fill( child: LayoutBuilder( key: const ValueKey('mall-entry-promo-pass-through-layer'), builder: (context, constraints) { final overlaySize = Size(constraints.maxWidth, constraints.maxHeight); final cardWidth = math.min( _cardWidth, math.max(0.0, overlaySize.width - 40), ); final desiredCardLeft = targetCenter.dx + _targetCenterToCardRight - cardWidth; final maximumCardLeft = math.max( 20.0, overlaySize.width - cardWidth - _cardRightInset, ); final cardLeft = desiredCardLeft .clamp(20.0, maximumCardLeft) .toDouble(); final desiredCardTop = targetCenter.dy - _anchorToCardBottom - _cardHeight; final minimumTop = MediaQuery.paddingOf(context).top + 16; final maximumTop = math.max( minimumTop, overlaySize.height - _cardHeight - 20, ); final cardTop = desiredCardTop .clamp(minimumTop, maximumTop) .toDouble(); final cardBottom = cardTop + _cardHeight; final pointerLeft = (targetCenter.dx - 9) .clamp(0.0, overlaySize.width - 18) .toDouble(); return Stack( clipBehavior: Clip.none, children: [ Positioned( left: cardLeft, top: cardTop, width: cardWidth, height: _cardHeight, child: Semantics( button: true, label: '进入商城', child: GestureDetector( key: const ValueKey('mall-entry-promo-card-action'), behavior: HitTestBehavior.opaque, onTap: widget.onCardTap, child: DecoratedBox( key: const ValueKey('mall-entry-promo-card'), decoration: BoxDecoration( color: const Color(0xE6FFFFFF), borderRadius: BorderRadius.circular(16), boxShadow: const [ BoxShadow( color: Color(0x33000000), offset: Offset(0, 8), blurRadius: 20, ), ], ), child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ SvgPicture.asset( 'assets/navigation/mall_entry_promo_gift.svg', key: const ValueKey('mall-entry-promo-gift'), width: 45, height: 40, fit: BoxFit.fill, ), const SizedBox(height: 4), if (title.isNotEmpty) SizedBox( width: double.infinity, height: 22, child: FittedBox( fit: BoxFit.scaleDown, child: Text( title, maxLines: 1, style: const TextStyle( color: Colors.black, fontSize: 16, fontWeight: FontWeight.w600, height: 1.375, ), ), ), ), if (title.isNotEmpty && body.isNotEmpty) const SizedBox(height: 4), if (body.isNotEmpty) Text( body, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: const TextStyle( color: Color(0x99000000), fontSize: 12, fontWeight: FontWeight.w400, height: 17 / 12, ), ), ], ), ), ), ), ), ), Positioned( left: pointerLeft, top: cardBottom - 1, width: 18, height: 16, child: IgnorePointer( child: SvgPicture.asset( 'assets/navigation/mall_entry_promo_pointer.svg', key: const ValueKey('mall-entry-promo-pointer'), fit: BoxFit.fill, ), ), ), ], ); }, ), ); } } class MallEntryPromoBadge extends StatelessWidget { const MallEntryPromoBadge({ super.key, required this.visible, required this.label, required this.child, this.top = -4, this.right = -20, }); final bool visible; final String label; final Widget child; final double top; final double right; static const _singleCharacterWidth = 30.0; static const _badgeHeight = 18.0; @override Widget build(BuildContext context) { final text = label.trim(); if (!visible || text.isEmpty) return child; final isSingleCharacter = text.runes.length == 1; return Stack( clipBehavior: Clip.none, children: [ child, Positioned( top: top, right: isSingleCharacter ? right : right + 8, child: IgnorePointer( child: Badge( key: const ValueKey('mall-entry-promo-badge'), backgroundColor: const Color(0xFFE7473C), textColor: Colors.white, largeSize: isSingleCharacter ? _badgeHeight : null, textStyle: const TextStyle( fontSize: 9, fontWeight: FontWeight.w600, height: 1, ), padding: isSingleCharacter ? EdgeInsets.zero : const EdgeInsets.symmetric(horizontal: 5, vertical: 2), label: isSingleCharacter ? SizedBox( key: const ValueKey('mall-entry-promo-badge-label'), width: _singleCharacterWidth, height: _badgeHeight, child: Center( child: Text( text, maxLines: 1, textAlign: TextAlign.center, strutStyle: const StrutStyle( height: 1, leading: 0, forceStrutHeight: true, ), ), ), ) : Text( text, key: const ValueKey('mall-entry-promo-badge-label'), maxLines: 1, ), ), ), ), ], ); } }