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;
}