feat: 对齐常驻商城与七鱼客服交互

- 同步商城 H5 登录态与原生 token
- 完善商城/原生 tab 切换、动画及返回交互
- 修复七鱼客服键盘避让和状态栏恢复
- 调整开屏跳过按钮与登录入口交互
This commit is contained in:
jingyun committed 2026-09-07 15:47:01 +08:00
1 parent b4343ea528
commit 3b089e2205
37 files changed
+2970 -375

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -5,6 +5,7 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:package_info_plus/package_info_plus.dart';
import '../../features/web/application/mall_web_host_controller.dart';
import '../storage/storage_service.dart';
/// Clears only disposable presentation data. Credentials and user preferences
@@ -14,6 +15,7 @@ class CacheClearService {
static Future<void> clear() async {
await DefaultCacheManager().emptyCache();
await MallWebHostController.instance.clear();
await SplashAdControllerPlaceholder.clear();
}
+184
View File
@@ -1,4 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_remix/flutter_remix.dart';
import 'package:not_static_icons/not_static_icons.dart' as nsi;
// flutter_remix 0.0.3: icon class is FlutterRemix
class AppIcons {
@@ -13,3 +17,183 @@ class AppIcons {
static const arrowRight = FlutterRemix.arrow_right_s_line;
static const arrowLeft = FlutterRemix.arrow_left_s_line;
}
enum AppBottomTabSymbol { assets, market, news, profile }
class AppBottomTabIcon extends StatefulWidget {
const AppBottomTabIcon({
required this.symbol,
required this.selected,
required this.unselectedColor,
required this.selectedColor,
this.size = 24,
super.key,
});
final AppBottomTabSymbol symbol;
final bool selected;
final Color unselectedColor;
final Color selectedColor;
final double size;
@override
State<AppBottomTabIcon> createState() => _AppBottomTabIconState();
}
class _AppBottomTabIconState extends State<AppBottomTabIcon>
with TickerProviderStateMixin {
static const _iconMotionDuration = Duration(milliseconds: 420);
static const _selectionDuration = Duration(milliseconds: 140);
static const _popDuration = Duration(milliseconds: 260);
static const _scalePeak = 1.06;
final nsi.AnimatedIconController _iconController =
nsi.AnimatedIconController();
late final AnimationController _selectionController = AnimationController(
vsync: this,
duration: _selectionDuration,
reverseDuration: _selectionDuration,
value: widget.selected ? 1 : 0,
);
late final AnimationController _popController = AnimationController(
vsync: this,
duration: _popDuration,
);
late final CurvedAnimation _colorProgress = CurvedAnimation(
parent: _selectionController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeOutCubic,
);
late final Animation<double> _scaleAnimation = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(
begin: 1.0,
end: _scalePeak,
).chain(CurveTween(curve: Curves.easeOutBack)),
weight: 55,
),
TweenSequenceItem(
tween: Tween(
begin: _scalePeak,
end: 1.0,
).chain(CurveTween(curve: Curves.easeOutCubic)),
weight: 45,
),
]).animate(_popController);
bool get _reduceMotion => MediaQuery.disableAnimationsOf(context);
@override
void initState() {
super.initState();
if (widget.selected) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_reduceMotion) _iconController.animate();
});
}
}
@override
void didUpdateWidget(covariant AppBottomTabIcon oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.selected == oldWidget.selected) return;
if (widget.selected) {
if (_reduceMotion) {
_selectionController.value = 1;
_popController.value = 0;
} else {
unawaited(_selectionController.forward(from: 0));
unawaited(_popController.forward(from: 0));
_iconController.animate();
}
} else {
if (_reduceMotion) {
_selectionController.value = 0;
} else {
unawaited(_selectionController.reverse());
}
_popController.value = 0;
_iconController.reset();
}
}
@override
void dispose() {
_colorProgress.dispose();
_selectionController.dispose();
_popController.dispose();
_iconController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final reduceMotion = _reduceMotion;
return SizedBox.square(
dimension: widget.size,
child: AnimatedBuilder(
animation: Listenable.merge([_selectionController, _popController]),
builder: (context, _) {
final color = Color.lerp(
widget.unselectedColor,
widget.selectedColor,
reduceMotion ? (widget.selected ? 1 : 0) : _colorProgress.value,
)!;
return Transform.scale(
scale: reduceMotion ? 1 : _scaleAnimation.value,
child: _buildBottomTabIcon(
widget.symbol,
size: widget.size,
color: color,
controller: _iconController,
),
);
},
),
);
}
}
Widget _buildBottomTabIcon(
AppBottomTabSymbol symbol, {
required double size,
required Color color,
required nsi.AnimatedIconController controller,
}) {
const strokeWidth = 2.05;
const duration = _AppBottomTabIconState._iconMotionDuration;
return switch (symbol) {
AppBottomTabSymbol.assets => nsi.CoinsIcon(
size: size,
color: color,
animationDuration: duration,
strokeWidth: strokeWidth,
interactive: false,
controller: controller,
),
AppBottomTabSymbol.market => nsi.ChartCandlestickIcon(
size: size,
color: color,
animationDuration: duration,
strokeWidth: strokeWidth,
interactive: false,
controller: controller,
),
AppBottomTabSymbol.news => nsi.BookOpenTextIcon(
size: size,
color: color,
animationDuration: duration,
strokeWidth: strokeWidth,
interactive: false,
controller: controller,
),
AppBottomTabSymbol.profile => nsi.CircleUserIcon(
size: size,
color: color,
animationDuration: duration,
strokeWidth: strokeWidth,
interactive: false,
controller: controller,
),
};
}
+60
View File
@@ -0,0 +1,60 @@
import 'dart:io' show Platform;
import 'package:android_intent_plus/android_intent.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:url_launcher/url_launcher.dart';
typedef AndroidIntentLauncher = Future<void> Function(AndroidIntent intent);
abstract final class AppLinkUtil {
static final _androidPackageNamePattern = RegExp(
r'^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$',
);
static String targetAppFromUrl(String urlString) {
final value = Uri.tryParse(urlString)?.queryParameters['targetApp']?.trim();
if (value == null || !_androidPackageNamePattern.hasMatch(value)) return '';
return value;
}
static Future<bool> openExternal(Uri uri, {String packageName = ''}) =>
AppLinkLauncher(
isAndroid: () => !kIsWeb && Platform.isAndroid,
launchAndroidIntent: (intent) => intent.launch(),
).openExternal(uri, packageName: packageName);
}
class AppLinkLauncher {
const AppLinkLauncher({
required this.isAndroid,
required this.launchAndroidIntent,
this.launchExternal,
});
final bool Function() isAndroid;
final AndroidIntentLauncher launchAndroidIntent;
final Future<bool> Function(Uri uri)? launchExternal;
Future<bool> openExternal(Uri uri, {String packageName = ''}) async {
if (uri.host.isEmpty ||
(uri.scheme.toLowerCase() != 'http' &&
uri.scheme.toLowerCase() != 'https')) {
return false;
}
if (isAndroid() && packageName.trim().isNotEmpty) {
try {
await launchAndroidIntent(
AndroidIntent(
action: 'action_view',
data: uri.toString(),
package: packageName.trim(),
),
);
return true;
} catch (_) {}
}
final launcher = launchExternal;
if (launcher != null) return launcher(uri);
return launchUrl(uri, mode: LaunchMode.externalApplication);
}
}