Files
jinzhi/lib/features/mall/presentation/widgets/mall_entry_promo.dart
T

346 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
),
),
),
],
);
}
}