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

+6 -3
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
@@ -16,7 +17,9 @@ class AuthApi {
Future<int> sendSmsCode(String phone) async {
final response = await _dio.post<List<int>>(
'/api/v1/auth/sms/send',
data: _ProtoWriter.stringField(1, phone),
// Dio may JSON-encode a List<int> as `[10,11,...]`. The API expects
// the raw protobuf bytes, so preserve the binary payload explicitly.
data: Uint8List.fromList(_ProtoWriter.stringField(1, phone)),
options: protobufOptions(),
);
return _ProtoMessage(response.data ?? const <int>[]).integer(1);
@@ -25,10 +28,10 @@ class AuthApi {
Future<AuthLoginResult> verifySmsCode(String phone, String code) async {
final response = await _dio.post<List<int>>(
'/api/v1/auth/sms/verify',
data: [
data: Uint8List.fromList([
..._ProtoWriter.stringField(1, phone),
..._ProtoWriter.stringField(2, code),
],
]),
options: protobufOptions(),
);
final reply = _ProtoMessage(response.data ?? const <int>[]);
@@ -0,0 +1,23 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../../../common/storage/storage_keys.dart';
class MallEntryPromoStore {
const MallEntryPromoStore(this._preferences);
final SharedPreferences _preferences;
bool get hasOpenedMall =>
_preferences.getBool(storageMallEntryPromoOpened) ?? false;
bool get hasSeenHomeBadge =>
_preferences.getBool(storageMallHomeBadgeSeen) ?? false;
Future<void> markMallOpened() async {
await _preferences.setBool(storageMallEntryPromoOpened, true);
}
Future<void> markHomeBadgeSeen() async {
await _preferences.setBool(storageMallHomeBadgeSeen, true);
}
}
@@ -19,7 +19,10 @@ class MallTabConfig {
this.adId = '',
this.trackingId = '',
this.openMode = 'in_app',
this.badgeText = '',
this.entryPromoEnabled = false,
this.entryBadgeText = '',
this.entryTipTitle = '',
this.entryTipBody = '',
});
const MallTabConfig.disabled() : this(enabled: false, url: '');
@@ -30,10 +33,19 @@ class MallTabConfig {
final String adId;
final String trackingId;
final String openMode;
final String badgeText;
final bool entryPromoEnabled;
final String entryBadgeText;
final String entryTipTitle;
final String entryTipBody;
bool get isVisible => enabled && initialUri != null;
bool get opensInApp => openMode == 'in_app';
bool get showsEntryBadge =>
isVisible && entryPromoEnabled && entryBadgeText.trim().isNotEmpty;
bool get showsEntryTip =>
isVisible &&
entryPromoEnabled &&
(entryTipTitle.trim().isNotEmpty || entryTipBody.trim().isNotEmpty);
Uri? get initialUri => attributedUri(clickId: '');
@@ -91,24 +103,8 @@ class MallTabConfigController extends StateNotifier<MallTabConfig> {
queryParameters: const {'placement': 'mall_tab'},
options: protobufOptions(),
);
final reply = _ProtoMessage(response.data ?? const []).message(2);
final enabled = _ProtoMessage(response.data ?? const []).integer(1) != 0;
final trackedUrl = reply.string(14);
final h5Url = reply.string(9);
final url = _validUrl(trackedUrl) ?? _validUrl(h5Url);
if (!enabled || url == null) {
state = const MallTabConfig.disabled();
return;
}
state = MallTabConfig(
enabled: true,
url: url,
title: _nonEmpty(reply.string(4), '商城'),
adId: reply.string(1),
trackingId: reply.string(10),
openMode: _openMode(reply.integer(15)),
badgeText: reply.string(17).trim(),
);
state = parseMallTabConfigResponse(response.data ?? const []);
if (!state.isVisible) return;
await _report('ad_impression');
} catch (_) {
state = const MallTabConfig.disabled();
@@ -216,6 +212,30 @@ String _openMode(int value) => switch (value) {
_ => 'external',
};
/// Parses the protobuf reply returned by `/api/v1/ads?placement=mall_tab`.
MallTabConfig parseMallTabConfigResponse(List<int> bytes) {
final response = _ProtoMessage(bytes);
final reply = response.message(2);
final trackedUrl = reply.string(14);
final h5Url = reply.string(9);
final url = _validUrl(trackedUrl) ?? _validUrl(h5Url);
if (response.integer(1) == 0 || url == null) {
return const MallTabConfig.disabled();
}
return MallTabConfig(
enabled: true,
url: url,
title: _nonEmpty(reply.string(4), '商城'),
adId: reply.string(1),
trackingId: reply.string(10),
openMode: _openMode(reply.integer(15)),
entryPromoEnabled: reply.integer(16) != 0,
entryBadgeText: reply.string(17).trim(),
entryTipTitle: reply.string(18).trim(),
entryTipBody: reply.string(19).trim(),
);
}
class _ProtoMessage {
_ProtoMessage(this._bytes);
final List<int> _bytes;
@@ -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,
),
),
),
),
],
);
}
}
@@ -49,32 +49,17 @@ class UpgradeInfo {
}
class UpdateApiClient {
UpdateApiClient(
this._dio, {
required String apiPathPrefix,
required String updatePackageName,
}) : _apiPathPrefix = _normalizePrefix(apiPathPrefix),
_updatePackageName = updatePackageName.trim();
UpdateApiClient(this._dio);
final Dio _dio;
final String _apiPathPrefix;
final String _updatePackageName;
Options get _options {
final options = protobufOptions();
if (_updatePackageName.isEmpty) return options;
return options.copyWith(
headers: {...?options.headers, 'pkg': _updatePackageName},
);
}
Future<UpgradeInfo> checkUpgrade() async {
final response = await _dio.post<List<int>>(
_path('/sys/upgrade'),
'/sys/upgrade',
// Uint8List ensures Dio sends the empty protobuf as 0 bytes instead of
// JSON-encoding an empty Dart List as `[]` (Content-Length: 2).
data: Uint8List(0), // jinzhi.v1.GetUpgradeRequest
options: _options,
options: protobufOptions(),
);
return UpgradeInfo.fromProtobuf(response.data ?? const <int>[]);
}
@@ -84,33 +69,18 @@ class UpdateApiClient {
required String source,
}) async {
await _dio.post<List<int>>(
_path('/active/upgrade/click'),
'/active/upgrade/click',
data: _ProtoWriter()
.string(1, buildId)
.string(2, source)
.toBytes(), // jinzhi.v1.UpgradeClickRequest
options: _options,
options: protobufOptions(),
);
}
String _path(String endpoint) => '$_apiPathPrefix$endpoint';
static String _normalizePrefix(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty || trimmed == '/') return '';
final withLeadingSlash = trimmed.startsWith('/') ? trimmed : '/$trimmed';
return withLeadingSlash.endsWith('/')
? withLeadingSlash.substring(0, withLeadingSlash.length - 1)
: withLeadingSlash;
}
}
final updateApiClientProvider = Provider<UpdateApiClient>(
(ref) => UpdateApiClient(
ref.watch(dioProvider),
apiPathPrefix: ref.watch(appConfigProvider).apiPathPrefix,
updatePackageName: ref.watch(appConfigProvider).updatePackageName,
),
(ref) => UpdateApiClient(ref.watch(dioProvider)),
);
class _ProtoFields {
@@ -58,7 +58,7 @@ class _MallWebHostState extends ConsumerState<MallWebHost>
statusBarBrightness: Brightness.light,
);
static const _webFilePickerChannel = MethodChannel(
'com.jinzhi.app/web_file_picker',
'com.jinzhi.butler/web_file_picker',
);
late final DWebViewController _controller;