feat: 对齐常驻商城与七鱼客服交互
- 同步商城 H5 登录态与原生 token - 完善商城/原生 tab 切换、动画及返回交互 - 修复七鱼客服键盘避让和状态栏恢复 - 调整开屏跳过按钮与登录入口交互
This commit is contained in:
1 parent
b4343ea528
commit
3b089e2205
37 files changed
+2970
-375
No files matched your search
+753
-232
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user