346 lines
11 KiB
Dart
346 lines
11 KiB
Dart
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_svg/flutter_svg.dart';
|
||
|
||
String _withChinesePunctuation(String value) {
|
||
const replacements = <String, String>{
|
||
':': ':',
|
||
',': ',',
|
||
';': ';',
|
||
'!': '!',
|
||
'?': '?',
|
||
'(': '(',
|
||
')': ')',
|
||
'[': '【',
|
||
']': '】',
|
||
'¥': '¥',
|
||
};
|
||
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<MallEntryPromoTooltip> createState() => _MallEntryPromoTooltipState();
|
||
}
|
||
|
||
class _MallEntryPromoTooltipState extends State<MallEntryPromoTooltip> {
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|