diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5a61e1e..45f9daa 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -33,7 +33,7 @@ fun requireSigningProperty(name: String): String { } android { - namespace = "com.jinzhi.app" + namespace = "com.jinzhi.butler" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -56,10 +56,9 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - // Temporary U-Verify test identity: the Yanting AppKey/login secret - // loaded from assets/env/.env is registered against this package. - applicationId = "com.yanting.read" + // Keep the registered Jinzhi application identity. One-key login and + // push providers validate the package-and-certificate pair. + applicationId = "com.jinzhi.butler" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/android/app/src/main/kotlin/com/jinzhi/app/JinzhiApplication.kt b/android/app/src/main/kotlin/com/jinzhi/butler/JinzhiApplication.kt similarity index 98% rename from android/app/src/main/kotlin/com/jinzhi/app/JinzhiApplication.kt rename to android/app/src/main/kotlin/com/jinzhi/butler/JinzhiApplication.kt index 6271f4c..22c5918 100644 --- a/android/app/src/main/kotlin/com/jinzhi/app/JinzhiApplication.kt +++ b/android/app/src/main/kotlin/com/jinzhi/butler/JinzhiApplication.kt @@ -1,4 +1,4 @@ -package com.jinzhi.app +package com.jinzhi.butler import android.app.Activity import android.content.pm.PackageManager diff --git a/android/app/src/main/kotlin/com/jinzhi/app/MainActivity.kt b/android/app/src/main/kotlin/com/jinzhi/butler/MainActivity.kt similarity index 99% rename from android/app/src/main/kotlin/com/jinzhi/app/MainActivity.kt rename to android/app/src/main/kotlin/com/jinzhi/butler/MainActivity.kt index bd90d43..d2513c3 100644 --- a/android/app/src/main/kotlin/com/jinzhi/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jinzhi/butler/MainActivity.kt @@ -1,4 +1,4 @@ -package com.jinzhi.app +package com.jinzhi.butler import android.app.Activity import android.content.ActivityNotFoundException @@ -16,7 +16,7 @@ import java.io.File class MainActivity : FlutterActivity() { companion object { - private const val WEB_FILE_PICKER_CHANNEL = "com.jinzhi.app/web_file_picker" + private const val WEB_FILE_PICKER_CHANNEL = "com.jinzhi.butler/web_file_picker" private const val WEB_FILE_PICKER_REQUEST_CODE = 9814 } diff --git a/assets/navigation/mall_entry_promo_decorative_star.svg b/assets/navigation/mall_entry_promo_decorative_star.svg new file mode 100644 index 0000000..5ddcdfe --- /dev/null +++ b/assets/navigation/mall_entry_promo_decorative_star.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/navigation/mall_entry_promo_gift.svg b/assets/navigation/mall_entry_promo_gift.svg new file mode 100644 index 0000000..bcf758f --- /dev/null +++ b/assets/navigation/mall_entry_promo_gift.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/navigation/mall_entry_promo_pointer.svg b/assets/navigation/mall_entry_promo_pointer.svg new file mode 100644 index 0000000..adac838 --- /dev/null +++ b/assets/navigation/mall_entry_promo_pointer.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 86e073a..607f525 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -300,10 +300,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -355,10 +359,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -491,14 +499,14 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = F38D68G8T6; + DEVELOPMENT_TEAM = 3QMFZJWPLS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read; + PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.butler; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -678,14 +686,14 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = F38D68G8T6; + DEVELOPMENT_TEAM = 3QMFZJWPLS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read; + PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.butler; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -705,14 +713,14 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = F38D68G8T6; + DEVELOPMENT_TEAM = 3QMFZJWPLS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.yanting.read; + PRODUCT_BUNDLE_IDENTIFIER = com.jinzhi.butler; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; diff --git a/lib/common/config/app_config.dart b/lib/common/config/app_config.dart index 29fca57..73db68a 100644 --- a/lib/common/config/app_config.dart +++ b/lib/common/config/app_config.dart @@ -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); } diff --git a/lib/common/router/tab_shell.dart b/lib/common/router/tab_shell.dart index 6690293..274de98 100644 --- a/lib/common/router/tab_shell.dart +++ b/lib/common/router/tab_shell.dart @@ -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 late final AnimationController _mallTransition; late final CurvedAnimation _mallProgress; late final ProviderSubscription _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 @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 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 } Future _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 } } + Future _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 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 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 minimize; final int currentIndex; final MallTabConfig mall; + final bool showMallEntryTip; + final bool showMallHomeBadge; final ValueChanged 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 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), + ], ), ); } diff --git a/lib/common/storage/storage_keys.dart b/lib/common/storage/storage_keys.dart index 6976afb..4688e07 100644 --- a/lib/common/storage/storage_keys.dart +++ b/lib/common/storage/storage_keys.dart @@ -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. diff --git a/lib/features/auth/data/auth_api.dart b/lib/features/auth/data/auth_api.dart index 490c263..6224f09 100644 --- a/lib/features/auth/data/auth_api.dart +++ b/lib/features/auth/data/auth_api.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:dio/dio.dart'; @@ -16,7 +17,9 @@ class AuthApi { Future sendSmsCode(String phone) async { final response = await _dio.post>( '/api/v1/auth/sms/send', - data: _ProtoWriter.stringField(1, phone), + // Dio may JSON-encode a List 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 []).integer(1); @@ -25,10 +28,10 @@ class AuthApi { Future verifySmsCode(String phone, String code) async { final response = await _dio.post>( '/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 []); diff --git a/lib/features/mall/application/mall_entry_promo_store.dart b/lib/features/mall/application/mall_entry_promo_store.dart new file mode 100644 index 0000000..8e0be82 --- /dev/null +++ b/lib/features/mall/application/mall_entry_promo_store.dart @@ -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 markMallOpened() async { + await _preferences.setBool(storageMallEntryPromoOpened, true); + } + + Future markHomeBadgeSeen() async { + await _preferences.setBool(storageMallHomeBadgeSeen, true); + } +} diff --git a/lib/features/mall/application/mall_tab_config.dart b/lib/features/mall/application/mall_tab_config.dart index ed8a350..e3f6601 100644 --- a/lib/features/mall/application/mall_tab_config.dart +++ b/lib/features/mall/application/mall_tab_config.dart @@ -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 { 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 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 _bytes; diff --git a/lib/features/mall/presentation/widgets/mall_entry_promo.dart b/lib/features/mall/presentation/widgets/mall_entry_promo.dart new file mode 100644 index 0000000..2d76d68 --- /dev/null +++ b/lib/features/mall/presentation/widgets/mall_entry_promo.dart @@ -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 = { + ':': ':', + ',': ',', + ';': ';', + '!': '!', + '?': '?', + '(': '(', + ')': ')', + '[': '【', + ']': '】', + '¥': '¥', + }; + 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 createState() => _MallEntryPromoTooltipState(); +} + +class _MallEntryPromoTooltipState extends State { + 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, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/settings/data/update_api_client.dart b/lib/features/settings/data/update_api_client.dart index b692743..eb4dfd1 100644 --- a/lib/features/settings/data/update_api_client.dart +++ b/lib/features/settings/data/update_api_client.dart @@ -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 checkUpgrade() async { final response = await _dio.post>( - _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 []); } @@ -84,33 +69,18 @@ class UpdateApiClient { required String source, }) async { await _dio.post>( - _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( - (ref) => UpdateApiClient( - ref.watch(dioProvider), - apiPathPrefix: ref.watch(appConfigProvider).apiPathPrefix, - updatePackageName: ref.watch(appConfigProvider).updatePackageName, - ), + (ref) => UpdateApiClient(ref.watch(dioProvider)), ); class _ProtoFields { diff --git a/lib/features/web/presentation/mall_web_host.dart b/lib/features/web/presentation/mall_web_host.dart index 151b3c3..fb9b5d1 100644 --- a/lib/features/web/presentation/mall_web_host.dart +++ b/lib/features/web/presentation/mall_web_host.dart @@ -58,7 +58,7 @@ class _MallWebHostState extends ConsumerState statusBarBrightness: Brightness.light, ); static const _webFilePickerChannel = MethodChannel( - 'com.jinzhi.app/web_file_picker', + 'com.jinzhi.butler/web_file_picker', ); late final DWebViewController _controller; diff --git a/pubspec.lock b/pubspec.lock index 77694f0..7e65e38 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -591,7 +591,7 @@ packages: source: hosted version: "0.1.3" flutter_svg: - dependency: transitive + dependency: "direct main" description: name: flutter_svg sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" diff --git a/pubspec.yaml b/pubspec.yaml index a3acc9a..251c824 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: shadcn_ui: ^0.53.6 google_fonts: ^6.2.1 flutter_remix: ^0.0.3 + flutter_svg: ^2.2.3 not_static_icons: ^0.46.0 shimmer: ^3.0.0 # 网络图片与磁盘缓存 @@ -112,3 +113,4 @@ flutter: - shorebird.yaml - assets/env/.env - assets/launch/ + - assets/navigation/ diff --git a/test/features/mall/mall_entry_promo_store_test.dart b/test/features/mall/mall_entry_promo_store_test.dart new file mode 100644 index 0000000..7ae4918 --- /dev/null +++ b/test/features/mall/mall_entry_promo_store_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jinzhi_flutter_app/features/mall/application/mall_entry_promo_store.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + test('records each first-install mall promotion exposure once', () async { + SharedPreferences.setMockInitialValues({}); + final store = MallEntryPromoStore(await SharedPreferences.getInstance()); + + expect(store.hasOpenedMall, isFalse); + expect(store.hasSeenHomeBadge, isFalse); + + await store.markMallOpened(); + await store.markHomeBadgeSeen(); + + expect(store.hasOpenedMall, isTrue); + expect(store.hasSeenHomeBadge, isTrue); + }); +} diff --git a/test/features/mall/mall_tab_config_test.dart b/test/features/mall/mall_tab_config_test.dart index 8e10995..7d356eb 100644 --- a/test/features/mall/mall_tab_config_test.dart +++ b/test/features/mall/mall_tab_config_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:jinzhi_flutter_app/features/mall/application/mall_tab_config.dart'; @@ -41,4 +43,69 @@ void main() { expect(nativeApp.opensInApp, isFalse); expect(external.opensInApp, isFalse); }); + + test('shows the first-install promo only for a configured mall entry', () { + const configured = MallTabConfig( + enabled: true, + url: 'https://mall.example.com', + entryPromoEnabled: true, + entryBadgeText: '新人礼', + entryTipTitle: '新人礼:¥360 铂金订购券', + entryTipBody: '点击这里进商城', + ); + const disabled = MallTabConfig( + enabled: true, + url: 'https://mall.example.com', + entryPromoEnabled: false, + entryTipTitle: '新人礼', + ); + + expect(configured.showsEntryBadge, isTrue); + expect(configured.showsEntryTip, isTrue); + expect(disabled.showsEntryBadge, isFalse); + expect(disabled.showsEntryTip, isFalse); + }); + + test('parses the server-provided mall entry promotion fields', () { + final creative = [ + ..._stringField(9, 'https://mall.example.com'), + ..._boolField(16, true), + ..._stringField(17, '新人礼'), + ..._stringField(18, '新人礼:¥360 铂金订购券'), + ..._stringField(19, '点击这里进商城'), + ]; + final config = parseMallTabConfigResponse([ + ..._boolField(1, true), + ..._bytesField(2, creative), + ]); + + expect(config.showsEntryBadge, isTrue); + expect(config.entryBadgeText, '新人礼'); + expect(config.entryTipTitle, '新人礼:¥360 铂金订购券'); + expect(config.entryTipBody, '点击这里进商城'); + }); +} + +List _stringField(int tag, String value) => + _bytesField(tag, utf8.encode(value)); + +List _bytesField(int tag, List value) => [ + ..._varint((tag << 3) | 2), + ..._varint(value.length), + ...value, +]; + +List _boolField(int tag, bool value) => [ + ..._varint(tag << 3), + value ? 1 : 0, +]; + +List _varint(int value) { + final bytes = []; + while (value > 127) { + bytes.add((value & 127) | 128); + value >>= 7; + } + bytes.add(value); + return bytes; }