fix:对齐金值的报名验证,商城入口逻辑对齐
This commit is contained in:
1 parent
3b089e2205
commit
7b1852d9a6
20 files changed
+682
-116
No files matched your search
@@ -4,7 +4,10 @@ class AppConfig {
|
||||
factory AppConfig.fromEnvironment() {
|
||||
const url = String.fromEnvironment(
|
||||
'JINZHI_API_BASE_URL',
|
||||
defaultValue: 'https://yanting.api.hnsiderui.cn',
|
||||
// defaultValue: 'http://localhost:8088',
|
||||
defaultValue: 'https://jinzhi.api.yqzhice.top',
|
||||
|
||||
// defaultValue: 'http://8.153.17.57',可测试ip
|
||||
);
|
||||
return const AppConfig(apiBaseUrl: url);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,17 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../features/mall/application/mall_entry_controller.dart';
|
||||
import '../../features/mall/application/mall_entry_promo_store.dart';
|
||||
import '../../features/mall/application/mall_tab_config.dart';
|
||||
import '../../features/mall/presentation/widgets/mall_entry_promo.dart';
|
||||
import '../../features/ads/application/startup_ad_service.dart';
|
||||
import '../../features/web/application/mall_bridge_state.dart';
|
||||
import '../../features/web/application/mall_web_host_controller.dart';
|
||||
import '../../features/web/presentation/mall_web_host.dart';
|
||||
import '../theme/app_icons.dart';
|
||||
import '../widgets/app_toast.dart';
|
||||
import 'app_router.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
|
||||
class TabShell extends ConsumerStatefulWidget {
|
||||
const TabShell({
|
||||
@@ -47,6 +51,10 @@ class _TabShellState extends ConsumerState<TabShell>
|
||||
late final AnimationController _mallTransition;
|
||||
late final CurvedAnimation _mallProgress;
|
||||
late final ProviderSubscription<MallTabConfig> _mallConfigSubscription;
|
||||
late final MallEntryPromoStore _mallEntryPromoStore;
|
||||
late bool _mallEntryTipConsumed;
|
||||
late bool _mallHomeBadgeSeen;
|
||||
bool _showMallHomeBadgeForPresentation = false;
|
||||
bool _userScrollSession = false;
|
||||
ScrollDirection _userScrollDirection = ScrollDirection.idle;
|
||||
double _directionAccum = 0;
|
||||
@@ -57,6 +65,9 @@ class _TabShellState extends ConsumerState<TabShell>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mallEntryPromoStore = MallEntryPromoStore(StorageService.to.preferences);
|
||||
_mallEntryTipConsumed = _mallEntryPromoStore.hasOpenedMall;
|
||||
_mallHomeBadgeSeen = _mallEntryPromoStore.hasSeenHomeBadge;
|
||||
MallWebHostController.instance.state.addListener(_syncMallHost);
|
||||
MallBridgeController.instance.state.addListener(_refreshMallDock);
|
||||
_minimizeController = AnimationController(
|
||||
@@ -130,10 +141,22 @@ class _TabShellState extends ConsumerState<TabShell>
|
||||
final state = MallWebHostController.instance.state.value;
|
||||
if (!mounted) return;
|
||||
final changed = _mallActive != state.visible;
|
||||
final showHomeBadge =
|
||||
changed &&
|
||||
state.visible &&
|
||||
!_mallHomeBadgeSeen &&
|
||||
ref.read(mallTabConfigProvider).showsEntryBadge;
|
||||
setState(() {
|
||||
_mallPresented |= state.url != null;
|
||||
_mallActive = state.visible;
|
||||
if (changed) {
|
||||
_showMallHomeBadgeForPresentation = showHomeBadge;
|
||||
if (showHomeBadge) _mallHomeBadgeSeen = true;
|
||||
}
|
||||
});
|
||||
if (showHomeBadge) {
|
||||
unawaited(_mallEntryPromoStore.markHomeBadgeSeen());
|
||||
}
|
||||
if (changed) {
|
||||
if (state.visible) {
|
||||
_mallTransition.forward();
|
||||
@@ -148,6 +171,8 @@ class _TabShellState extends ConsumerState<TabShell>
|
||||
}
|
||||
|
||||
Future<void> _openMall(MallTabConfig config) async {
|
||||
await _consumeMallEntryTip(config);
|
||||
if (!mounted) return;
|
||||
final result = await ref
|
||||
.read(mallEntryControllerProvider)
|
||||
.open(context, initialConfig: config);
|
||||
@@ -159,6 +184,20 @@ class _TabShellState extends ConsumerState<TabShell>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _consumeMallEntryTip(MallTabConfig config) async {
|
||||
if (!config.showsEntryTip || _mallEntryTipConsumed) return;
|
||||
setState(() => _mallEntryTipConsumed = true);
|
||||
await _mallEntryPromoStore.markMallOpened();
|
||||
}
|
||||
|
||||
bool get _isPrimaryTabRoot => switch (widget.location) {
|
||||
AppRoutes.assets ||
|
||||
AppRoutes.market ||
|
||||
AppRoutes.news ||
|
||||
AppRoutes.profile => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TabShell oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -173,6 +212,7 @@ class _TabShellState extends ConsumerState<TabShell>
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final mall = ref.watch(mallTabConfigProvider);
|
||||
final startupAdResolved = ref.watch(startupAdResolvedProvider);
|
||||
final isQiyuKeyboardActive =
|
||||
MallWebHostController.instance.state.value.isQiyuKeyboardActive;
|
||||
final header = _headerForPath(widget.location);
|
||||
@@ -312,6 +352,14 @@ class _TabShellState extends ConsumerState<TabShell>
|
||||
minimize: _minimizeFactor,
|
||||
currentIndex: _index,
|
||||
mall: mall,
|
||||
showMallEntryTip:
|
||||
startupAdResolved &&
|
||||
_isPrimaryTabRoot &&
|
||||
!_mallActive &&
|
||||
!_mallEntryTipConsumed &&
|
||||
mall.showsEntryTip,
|
||||
showMallHomeBadge:
|
||||
_showMallHomeBadgeForPresentation && mall.showsEntryBadge,
|
||||
onTabTap: (index) => widget.navigationShell.goBranch(
|
||||
index,
|
||||
initialLocation: index == _index,
|
||||
@@ -437,6 +485,8 @@ class _MorphingNavigationDock extends StatelessWidget {
|
||||
required this.minimize,
|
||||
required this.currentIndex,
|
||||
required this.mall,
|
||||
required this.showMallEntryTip,
|
||||
required this.showMallHomeBadge,
|
||||
required this.onTabTap,
|
||||
required this.onMallTap,
|
||||
});
|
||||
@@ -445,6 +495,8 @@ class _MorphingNavigationDock extends StatelessWidget {
|
||||
final Animation<double> minimize;
|
||||
final int currentIndex;
|
||||
final MallTabConfig mall;
|
||||
final bool showMallEntryTip;
|
||||
final bool showMallHomeBadge;
|
||||
final ValueChanged<int> onTabTap;
|
||||
final VoidCallback onMallTap;
|
||||
|
||||
@@ -545,6 +597,7 @@ class _MorphingNavigationDock extends StatelessWidget {
|
||||
opacity: (1 - t * 2).clamp(0.0, 1.0),
|
||||
child: _MallLauncherButton(
|
||||
mall: mall,
|
||||
showPromoTip: showMallEntryTip,
|
||||
onTap: onMallTap,
|
||||
),
|
||||
),
|
||||
@@ -570,6 +623,9 @@ class _MorphingNavigationDock extends StatelessWidget {
|
||||
.tabs[index]
|
||||
.$1,
|
||||
),
|
||||
promoBadgeIndex: 0,
|
||||
promoBadgeVisible: showMallHomeBadge,
|
||||
promoBadgeLabel: mall.entryBadgeText,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -591,37 +647,48 @@ class _MorphingNavigationDock extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _MallLauncherButton extends StatelessWidget {
|
||||
const _MallLauncherButton({required this.mall, required this.onTap});
|
||||
const _MallLauncherButton({
|
||||
required this.mall,
|
||||
required this.showPromoTip,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final MallTabConfig mall;
|
||||
final bool showPromoTip;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: mall.title,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [cs.primary.withValues(alpha: 0.78), cs.primary],
|
||||
return MallEntryPromoTooltip(
|
||||
visible: showPromoTip,
|
||||
title: mall.entryTipTitle,
|
||||
body: mall.entryTipBody,
|
||||
onCardTap: onTap,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: mall.title,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [cs.primary.withValues(alpha: 0.78), cs.primary],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.shopping_bag,
|
||||
size: 25,
|
||||
color: cs.primaryForeground,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.shopping_bag,
|
||||
size: 25,
|
||||
color: cs.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -651,6 +718,9 @@ class _ExpandingDockTabStrip extends StatelessWidget {
|
||||
required this.selectedIndex,
|
||||
required this.onTap,
|
||||
this.minimize = 0,
|
||||
this.promoBadgeIndex,
|
||||
this.promoBadgeVisible = false,
|
||||
this.promoBadgeLabel = '',
|
||||
});
|
||||
|
||||
static const double _inset = 6;
|
||||
@@ -660,6 +730,9 @@ class _ExpandingDockTabStrip extends StatelessWidget {
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onTap;
|
||||
final double minimize;
|
||||
final int? promoBadgeIndex;
|
||||
final bool promoBadgeVisible;
|
||||
final String promoBadgeLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -676,6 +749,7 @@ class _ExpandingDockTabStrip extends StatelessWidget {
|
||||
final indicatorLeft = _inset + safeIndex * compactWidth;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 260),
|
||||
@@ -697,13 +771,19 @@ class _ExpandingDockTabStrip extends StatelessWidget {
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: index == safeIndex ? selectedWidth : compactWidth,
|
||||
child: _ExpandingDockTab(
|
||||
item: items[index],
|
||||
selected: index == safeIndex,
|
||||
labelFactor: index == safeIndex
|
||||
? (1 - minimize).clamp(0.0, 1.0)
|
||||
: 0,
|
||||
onTap: () => onTap(index),
|
||||
child: MallEntryPromoBadge(
|
||||
visible: index == promoBadgeIndex && promoBadgeVisible,
|
||||
label: promoBadgeLabel,
|
||||
top: -2,
|
||||
right: 0,
|
||||
child: _ExpandingDockTab(
|
||||
item: items[index],
|
||||
selected: index == safeIndex,
|
||||
labelFactor: index == safeIndex
|
||||
? (1 - minimize).clamp(0.0, 1.0)
|
||||
: 0,
|
||||
onTap: () => onTap(index),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -859,19 +939,28 @@ class _MallDockSurface extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.background.withValues(alpha: 0.82),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
// Clip only the frosted background. The mall promotion badge is
|
||||
// intentionally positioned above the pill and must remain visible.
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
border: Border.all(color: cs.border.withValues(alpha: 0.8)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.background.withValues(alpha: 0.82),
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
border: Border.all(color: cs.border.withValues(alpha: 0.8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
|
||||
const storageJinzhiPriceColorMode = 'jinzhi_price_color_mode';
|
||||
const storageJinzhiMockLoggedIn = 'jinzhi_mock_logged_in';
|
||||
const storagePushPermissionPrompted = 'push_permission_prompted';
|
||||
const storageMallEntryPromoOpened = 'jinzhi_mall_entry_promo_opened';
|
||||
const storageMallHomeBadgeSeen = 'jinzhi_mall_home_badge_seen';
|
||||
|
||||
// Previous builds could persist credentials in SharedPreferences. These keys
|
||||
// exist only for one-time migration into secure storage.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user