fix:对齐金值的报名验证,商城入口逻辑对齐

This commit is contained in:
jingyun committed 2026-09-08 10:56:33 +08:00
1 parent 3b089e2205
commit 7b1852d9a6
20 files changed
+682 -116

No files matched your search

@@ -0,0 +1,345 @@
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,
),
),
),
),
],
);
}
}