feat(toast): 实现原生Toast系统并优化会员购买错误处理
- 新增iOS原生Toast模块(NativeToastManager),提供毛玻璃风格的Toast展示 - 重构ToastContext为原生模块调用,添加错误边界和回退机制 - 优化会员购买流程的错误处理,使用RevenueCat标准错误码 - 调整购买按钮高度和恢复购买按钮字体大小,改善UI体验 - 移除不必要的延迟和注释代码,提升代码质量
This commit is contained in:
@@ -1,8 +1,6 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import CustomCheckBox from '@/components/ui/CheckBox';
|
import CustomCheckBox from '@/components/ui/CheckBox';
|
||||||
import { USER_AGREEMENT_URL } from '@/constants/Agree';
|
import { USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||||
// import { useAuth } from '@/contexts/AuthContext';
|
|
||||||
// import { UserApi } from '@/services';
|
|
||||||
import { log, logger } from '@/utils/logger';
|
import { log, logger } from '@/utils/logger';
|
||||||
import {
|
import {
|
||||||
captureMessage,
|
captureMessage,
|
||||||
@@ -275,9 +273,6 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
|||||||
capturePurchaseEvent('init', '开始获取会员产品套餐');
|
capturePurchaseEvent('init', '开始获取会员产品套餐');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 添加延迟,确保 RevenueCat SDK 完全初始化
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
const offerings = await Purchases.getOfferings();
|
const offerings = await Purchases.getOfferings();
|
||||||
log.info('获取产品套餐', { offerings });
|
log.info('获取产品套餐', { offerings });
|
||||||
|
|
||||||
@@ -639,27 +634,27 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 处理不同类型的购买错误
|
// 处理不同类型的购买错误
|
||||||
if (error.code === 1 || error.code === 'USER_CANCELLED') {
|
if (error.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
|
||||||
// 用户取消购买
|
// 用户取消购买
|
||||||
GlobalToast.show({
|
GlobalToast.show({
|
||||||
message: '购买已取消',
|
message: '购买已取消',
|
||||||
});
|
});
|
||||||
} else if (error.code === 'ITEM_ALREADY_OWNED' || error.code === 'PRODUCT_ALREADY_PURCHASED') {
|
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR) {
|
||||||
// 商品已拥有
|
// 商品已拥有
|
||||||
GlobalToast.show({
|
GlobalToast.show({
|
||||||
message: '您已拥有此商品',
|
message: '您已拥有此商品',
|
||||||
});
|
});
|
||||||
} else if (error.code === 'NETWORK_ERROR') {
|
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) {
|
||||||
// 网络错误
|
// 网络错误
|
||||||
GlobalToast.show({
|
GlobalToast.show({
|
||||||
message: '网络连接失败',
|
message: '网络连接失败',
|
||||||
});
|
});
|
||||||
} else if (error.code === 'PAYMENT_PENDING') {
|
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
|
||||||
// 支付待处理
|
// 支付待处理
|
||||||
GlobalToast.show({
|
GlobalToast.show({
|
||||||
message: '支付正在处理中',
|
message: '支付正在处理中',
|
||||||
});
|
});
|
||||||
} else if (error.code === 'INVALID_CREDENTIALS') {
|
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) {
|
||||||
// 凭据无效
|
// 凭据无效
|
||||||
GlobalToast.show({
|
GlobalToast.show({
|
||||||
message: '账户验证失败',
|
message: '账户验证失败',
|
||||||
@@ -813,15 +808,15 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
|||||||
capturePurchaseEvent('error', `恢复购买失败: ${errorData.message}`, errorData);
|
capturePurchaseEvent('error', `恢复购买失败: ${errorData.message}`, errorData);
|
||||||
|
|
||||||
// 处理特定的恢复购买错误
|
// 处理特定的恢复购买错误
|
||||||
if (error.code === 'RESTORE_CANCELLED' || error.code === 'USER_CANCELLED') {
|
if (error.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
|
||||||
GlobalToast.show({
|
GlobalToast.show({
|
||||||
message: '恢复购买已取消',
|
message: '恢复购买已取消',
|
||||||
});
|
});
|
||||||
} else if (error.code === 'NETWORK_ERROR') {
|
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) {
|
||||||
GlobalToast.show({
|
GlobalToast.show({
|
||||||
message: '网络错误',
|
message: '网络错误',
|
||||||
});
|
});
|
||||||
} else if (error.code === 'INVALID_CREDENTIALS') {
|
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) {
|
||||||
GlobalToast.show({
|
GlobalToast.show({
|
||||||
message: '账户验证失败',
|
message: '账户验证失败',
|
||||||
});
|
});
|
||||||
@@ -1400,7 +1395,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
purchaseButton: {
|
purchaseButton: {
|
||||||
borderRadius: 28,
|
borderRadius: 28,
|
||||||
height: 52,
|
height: 64,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -1465,7 +1460,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
restoreButtonText: {
|
restoreButtonText: {
|
||||||
color: '#6F6F7A',
|
color: '#6F6F7A',
|
||||||
fontSize: 14,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
disabledRestoreButton: {
|
disabledRestoreButton: {
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import SuccessToast from '@/components/ui/SuccessToast';
|
import { Toast, ToastConfig } from '@/utils/toast.utils';
|
||||||
import { Colors } from '@/constants/Colors';
|
import React, { createContext, useContext, useMemo } from 'react';
|
||||||
import { setToastRef } from '@/utils/toast.utils';
|
import { Alert, Platform } from 'react-native';
|
||||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
interface ToastConfig {
|
|
||||||
message: string;
|
|
||||||
duration?: number;
|
|
||||||
backgroundColor?: string;
|
|
||||||
textColor?: string;
|
|
||||||
icon?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToastContextType {
|
export interface ToastContextType {
|
||||||
showToast: (config: ToastConfig) => void;
|
showToast: (config: ToastConfig) => void;
|
||||||
@@ -21,88 +12,76 @@ export interface ToastContextType {
|
|||||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [visible, setVisible] = useState(false);
|
const value = useMemo<ToastContextType>(
|
||||||
const [config, setConfig] = useState<ToastConfig>({ message: '' });
|
() => ({
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
showToast: (config: ToastConfig) => {
|
||||||
|
try {
|
||||||
const showToast = (toastConfig: ToastConfig) => {
|
Toast.show(config);
|
||||||
// 如果已有Toast显示,先隐藏
|
} catch (error) {
|
||||||
if (visible) {
|
console.error('Toast.show failed:', error);
|
||||||
setVisible(false);
|
// 错误边界:在原生模块失败时提供回退方案
|
||||||
// 短暂延迟后显示新Toast
|
handleToastFallback(config.message || config.text1 || '提示', config.type);
|
||||||
setTimeout(() => {
|
}
|
||||||
setConfig(toastConfig);
|
},
|
||||||
setVisible(true);
|
showSuccess: (message: string, duration?: number) => {
|
||||||
}, 100);
|
try {
|
||||||
} else {
|
Toast.success(message, duration);
|
||||||
setConfig(toastConfig);
|
} catch (error) {
|
||||||
setVisible(true);
|
console.error('Toast.success failed:', error);
|
||||||
}
|
handleToastFallback(message, 'success');
|
||||||
};
|
}
|
||||||
|
},
|
||||||
const showSuccess = (message: string, duration?: number) => {
|
showError: (message: string, duration?: number) => {
|
||||||
showToast({
|
try {
|
||||||
message,
|
Toast.error(message, duration);
|
||||||
duration,
|
} catch (error) {
|
||||||
backgroundColor: Colors.light.primary, // 主题色
|
console.error('Toast.error failed:', error);
|
||||||
icon: '✓',
|
handleToastFallback(message, 'error');
|
||||||
});
|
}
|
||||||
};
|
},
|
||||||
|
showWarning: (message: string, duration?: number) => {
|
||||||
const showError = (message: string, duration?: number) => {
|
try {
|
||||||
showToast({
|
Toast.warning(message, duration);
|
||||||
message,
|
} catch (error) {
|
||||||
duration,
|
console.error('Toast.warning failed:', error);
|
||||||
backgroundColor: '#f44336', // 红色
|
handleToastFallback(message, 'warning');
|
||||||
icon: '✕',
|
}
|
||||||
});
|
},
|
||||||
};
|
}),
|
||||||
|
[]
|
||||||
const showWarning = (message: string, duration?: number) => {
|
);
|
||||||
showToast({
|
|
||||||
message,
|
|
||||||
duration,
|
|
||||||
backgroundColor: '#ff9800', // 橙色
|
|
||||||
icon: '⚠',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHide = () => {
|
|
||||||
setVisible(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const value: ToastContextType = {
|
|
||||||
showToast,
|
|
||||||
showSuccess,
|
|
||||||
showError,
|
|
||||||
showWarning,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 设置全局引用
|
|
||||||
useEffect(() => {
|
|
||||||
setToastRef(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={value}>
|
<ToastContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
<SuccessToast
|
|
||||||
visible={visible}
|
|
||||||
message={config.message}
|
|
||||||
duration={config.duration}
|
|
||||||
backgroundColor={config.backgroundColor}
|
|
||||||
textColor={config.textColor}
|
|
||||||
icon={config.icon}
|
|
||||||
onHide={handleHide}
|
|
||||||
/>
|
|
||||||
</ToastContext.Provider>
|
</ToastContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误边界处理:在原生 Toast 模块失败时提供回退方案
|
||||||
|
*/
|
||||||
|
function handleToastFallback(message: string, type?: string) {
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
// 在 iOS 上使用 Alert 作为回退方案,避免过度打扰用户
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
// 只对错误和警告类型使用 Alert,其他类型仅记录到控制台
|
||||||
|
if (type === 'error' || type === 'warning') {
|
||||||
|
Alert.alert('', message, [{ text: '确定', style: 'default' }]);
|
||||||
|
} else {
|
||||||
|
console.log(`Toast (${type}): ${message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 其他平台仅记录到控制台
|
||||||
|
console.log(`Toast (${type}): ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useToast(): ToastContextType {
|
export function useToast(): ToastContextType {
|
||||||
const context = useContext(ToastContext);
|
const context = useContext(ToastContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error('useToast must be used within a ToastProvider');
|
throw new Error('useToast must be used within a ToastProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */; };
|
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */; };
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||||
792C52592EA880A7002F3F09 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792C52582EA880A7002F3F09 /* StoreKit.framework */; };
|
792C52592EA880A7002F3F09 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792C52582EA880A7002F3F09 /* StoreKit.framework */; };
|
||||||
|
792C52622EB05B8F002F3F09 /* NativeToastManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 792C52602EB05B8F002F3F09 /* NativeToastManager.m */; };
|
||||||
|
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792C52612EB05B8F002F3F09 /* NativeToastManager.swift */; };
|
||||||
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
|
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
|
||||||
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
|
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
|
||||||
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
|
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
|
||||||
@@ -28,6 +30,8 @@
|
|||||||
1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OutLive/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OutLive/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||||
6F6136AA7113B3D210693D88 /* libPods-OutLive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OutLive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
6F6136AA7113B3D210693D88 /* libPods-OutLive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OutLive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
792C52582EA880A7002F3F09 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
|
792C52582EA880A7002F3F09 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
|
||||||
|
792C52602EB05B8F002F3F09 /* NativeToastManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = NativeToastManager.m; path = OutLive/NativeToastManager.m; sourceTree = "<group>"; };
|
||||||
|
792C52612EB05B8F002F3F09 /* NativeToastManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NativeToastManager.swift; path = OutLive/NativeToastManager.swift; sourceTree = "<group>"; };
|
||||||
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
|
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
|
||||||
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
|
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
|
||||||
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
|
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
@@ -94,6 +98,8 @@
|
|||||||
83CBB9F61A601CBA00E9B192 = {
|
83CBB9F61A601CBA00E9B192 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
792C52602EB05B8F002F3F09 /* NativeToastManager.m */,
|
||||||
|
792C52612EB05B8F002F3F09 /* NativeToastManager.swift */,
|
||||||
79B2CB712E7B954F00B51753 /* HealthKitManager.m */,
|
79B2CB712E7B954F00B51753 /* HealthKitManager.m */,
|
||||||
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */,
|
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */,
|
||||||
13B07FAE1A68108700A75B9A /* OutLive */,
|
13B07FAE1A68108700A75B9A /* OutLive */,
|
||||||
@@ -359,6 +365,8 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
792C52622EB05B8F002F3F09 /* NativeToastManager.m in Sources */,
|
||||||
|
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */,
|
||||||
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */,
|
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */,
|
||||||
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */,
|
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */,
|
||||||
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */,
|
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */,
|
||||||
|
|||||||
7
ios/OutLive/NativeToastManager.m
Normal file
7
ios/OutLive/NativeToastManager.m
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#import <React/RCTBridgeModule.h>
|
||||||
|
|
||||||
|
@interface RCT_EXTERN_MODULE(NativeToastManager, NSObject)
|
||||||
|
|
||||||
|
RCT_EXTERN_METHOD(show:(NSDictionary *)options)
|
||||||
|
|
||||||
|
@end
|
||||||
514
ios/OutLive/NativeToastManager.swift
Normal file
514
ios/OutLive/NativeToastManager.swift
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
//
|
||||||
|
// NativeToastManager.swift
|
||||||
|
// OutLive
|
||||||
|
//
|
||||||
|
// A native toast presenter styled to mirror the app's glassy, elevated UI.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@objc(NativeToastManager)
|
||||||
|
class NativeToastManager: NSObject {
|
||||||
|
|
||||||
|
private var currentToastView: ToastView?
|
||||||
|
private var hideWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
|
@objc
|
||||||
|
static func requiresMainQueueSetup() -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
func show(_ options: NSDictionary) {
|
||||||
|
let payload = ToastPayload(dictionary: options)
|
||||||
|
guard payload.isValid else { return }
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.presentToast(with: payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Presentation
|
||||||
|
|
||||||
|
private func presentToast(with payload: ToastPayload) {
|
||||||
|
guard let window = keyWindow else { return }
|
||||||
|
|
||||||
|
hideWorkItem?.cancel()
|
||||||
|
hideWorkItem = nil
|
||||||
|
|
||||||
|
if let current = currentToastView {
|
||||||
|
dismissToast(current, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastView = ToastView(payload: payload)
|
||||||
|
currentToastView = toastView
|
||||||
|
|
||||||
|
window.addSubview(toastView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
toastView.centerXAnchor.constraint(equalTo: window.centerXAnchor),
|
||||||
|
toastView.topAnchor.constraint(equalTo: window.safeAreaLayoutGuide.topAnchor, constant: 18),
|
||||||
|
toastView.leadingAnchor.constraint(greaterThanOrEqualTo: window.leadingAnchor, constant: 20),
|
||||||
|
toastView.trailingAnchor.constraint(lessThanOrEqualTo: window.trailingAnchor, constant: -20)
|
||||||
|
])
|
||||||
|
|
||||||
|
window.layoutIfNeeded()
|
||||||
|
|
||||||
|
toastView.alpha = 0
|
||||||
|
toastView.transform = CGAffineTransform(translationX: 0, y: -26).scaledBy(x: 0.9, y: 0.9)
|
||||||
|
|
||||||
|
let notificationFeedback = UINotificationFeedbackGenerator()
|
||||||
|
let selectionFeedback = UISelectionFeedbackGenerator()
|
||||||
|
|
||||||
|
if payload.style.feedbackType != nil {
|
||||||
|
notificationFeedback.prepare()
|
||||||
|
} else {
|
||||||
|
selectionFeedback.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: 0.55,
|
||||||
|
delay: 0,
|
||||||
|
usingSpringWithDamping: 0.82,
|
||||||
|
initialSpringVelocity: 0.6,
|
||||||
|
options: [.curveEaseOut, .allowUserInteraction]
|
||||||
|
) {
|
||||||
|
toastView.alpha = 1
|
||||||
|
toastView.transform = .identity
|
||||||
|
} completion: { _ in
|
||||||
|
if let feedback = payload.style.feedbackType {
|
||||||
|
notificationFeedback.notificationOccurred(feedback)
|
||||||
|
} else {
|
||||||
|
selectionFeedback.selectionChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
if UIAccessibility.isVoiceOverRunning {
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: payload.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let workItem = DispatchWorkItem { [weak self, weak toastView] in
|
||||||
|
guard let toastView, let self else { return }
|
||||||
|
self.dismissToast(toastView, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
hideWorkItem = workItem
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + payload.duration, execute: workItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissToast(_ toastView: ToastView, animated: Bool) {
|
||||||
|
let cleanup: () -> Void = { [weak self, weak toastView] in
|
||||||
|
guard let toastView else { return }
|
||||||
|
toastView.removeFromSuperview()
|
||||||
|
if self?.currentToastView === toastView {
|
||||||
|
self?.currentToastView = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard animated else {
|
||||||
|
cleanup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: 0.28,
|
||||||
|
delay: 0,
|
||||||
|
options: [.curveEaseIn, .allowUserInteraction]
|
||||||
|
) {
|
||||||
|
toastView.alpha = 0
|
||||||
|
toastView.transform = CGAffineTransform(translationX: 0, y: -22).scaledBy(x: 0.88, y: 0.88)
|
||||||
|
} completion: { _ in
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Window Discovery
|
||||||
|
|
||||||
|
private var keyWindow: UIWindow? {
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
return UIApplication.shared.connectedScenes
|
||||||
|
.compactMap { $0 as? UIWindowScene }
|
||||||
|
.first(where: { $0.activationState == .foregroundActive })?
|
||||||
|
.windows
|
||||||
|
.first(where: { $0.isKeyWindow })
|
||||||
|
} else {
|
||||||
|
#if !os(tvOS)
|
||||||
|
return UIApplication.shared.keyWindow
|
||||||
|
#else
|
||||||
|
return nil
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Payload & Style Models
|
||||||
|
|
||||||
|
private struct ToastPayload {
|
||||||
|
let message: String
|
||||||
|
let duration: TimeInterval
|
||||||
|
let style: ToastStyle
|
||||||
|
|
||||||
|
var isValid: Bool {
|
||||||
|
!message.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
init(dictionary: NSDictionary) {
|
||||||
|
let primaryMessage = (dictionary["message"] as? String)?.trimmed ?? ""
|
||||||
|
let fallbackMessage = (dictionary["text1"] as? String)?.trimmed ?? ""
|
||||||
|
message = primaryMessage.isEmpty ? fallbackMessage : primaryMessage
|
||||||
|
|
||||||
|
let durationMs = dictionary["duration"] as? Double ?? 2000
|
||||||
|
duration = max(1.0, min(durationMs / 1000.0, 10.0))
|
||||||
|
|
||||||
|
let type = (dictionary["type"] as? String) ?? "default"
|
||||||
|
let overrides = ToastStyleOverrides(
|
||||||
|
backgroundHex: dictionary["backgroundColor"] as? String,
|
||||||
|
textHex: dictionary["textColor"] as? String,
|
||||||
|
icon: dictionary["icon"] as? String
|
||||||
|
)
|
||||||
|
|
||||||
|
style = ToastStyle(styleName: type, overrides: overrides)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ToastStyleOverrides {
|
||||||
|
let backgroundColor: UIColor?
|
||||||
|
let textColor: UIColor?
|
||||||
|
let icon: String?
|
||||||
|
|
||||||
|
init(backgroundHex: String?, textHex: String?, icon: String?) {
|
||||||
|
backgroundColor = backgroundHex.flatMap { UIColor.fromHexString($0) }
|
||||||
|
textColor = textHex.flatMap { UIColor.fromHexString($0) }
|
||||||
|
self.icon = icon?.trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ToastIcon {
|
||||||
|
case system(name: String)
|
||||||
|
case glyph(String)
|
||||||
|
case none
|
||||||
|
|
||||||
|
static func from(raw: String?, fallback: ToastIcon) -> ToastIcon {
|
||||||
|
guard let trimmed = raw?.trimmed, !trimmed.isEmpty else {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
switch trimmed.lowercased() {
|
||||||
|
case "none", "null":
|
||||||
|
return .none
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if UIImage(systemName: trimmed) != nil {
|
||||||
|
return .system(name: trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .glyph(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasVisual: Bool {
|
||||||
|
switch self {
|
||||||
|
case .none:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ToastStyle {
|
||||||
|
let gradientColors: [UIColor]
|
||||||
|
let textColor: UIColor
|
||||||
|
let icon: ToastIcon
|
||||||
|
let iconTint: UIColor
|
||||||
|
let iconBackground: UIColor
|
||||||
|
let borderColor: UIColor
|
||||||
|
let shadowColor: UIColor
|
||||||
|
let feedbackType: UINotificationFeedbackGenerator.FeedbackType?
|
||||||
|
|
||||||
|
init(styleName: String, overrides: ToastStyleOverrides) {
|
||||||
|
let baseColor: UIColor
|
||||||
|
let defaultIcon: ToastIcon
|
||||||
|
let defaultFeedback: UINotificationFeedbackGenerator.FeedbackType?
|
||||||
|
|
||||||
|
switch styleName.lowercased() {
|
||||||
|
case "success":
|
||||||
|
baseColor = Palette.success
|
||||||
|
defaultIcon = .system(name: "checkmark.seal.fill")
|
||||||
|
defaultFeedback = .success
|
||||||
|
case "error", "danger", "fail":
|
||||||
|
baseColor = Palette.error
|
||||||
|
defaultIcon = .system(name: "xmark.octagon.fill")
|
||||||
|
defaultFeedback = .error
|
||||||
|
case "warning":
|
||||||
|
baseColor = Palette.warning
|
||||||
|
defaultIcon = .system(name: "exclamationmark.triangle.fill")
|
||||||
|
defaultFeedback = .warning
|
||||||
|
case "info":
|
||||||
|
baseColor = Palette.info
|
||||||
|
defaultIcon = .system(name: "info.circle.fill")
|
||||||
|
defaultFeedback = nil
|
||||||
|
default:
|
||||||
|
baseColor = Palette.primary
|
||||||
|
defaultIcon = .system(name: "sparkles")
|
||||||
|
defaultFeedback = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let accent = overrides.backgroundColor ?? baseColor
|
||||||
|
textColor = overrides.textColor ?? .white
|
||||||
|
iconTint = textColor
|
||||||
|
icon = ToastIcon.from(raw: overrides.icon, fallback: defaultIcon)
|
||||||
|
gradientColors = accent.gradientPair
|
||||||
|
iconBackground = accent.lighten(by: 0.3).withAlphaComponent(0.22)
|
||||||
|
borderColor = accent.lighten(by: 0.55).withAlphaComponent(0.32)
|
||||||
|
shadowColor = accent.darken(by: 0.55).withAlphaComponent(0.5)
|
||||||
|
feedbackType = defaultFeedback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toast View
|
||||||
|
|
||||||
|
private final class ToastView: UIView {
|
||||||
|
|
||||||
|
private let payload: ToastPayload
|
||||||
|
private let gradientLayer = CAGradientLayer()
|
||||||
|
private let highlightLayer = CALayer()
|
||||||
|
private let backgroundView = UIView()
|
||||||
|
private let iconImageView = UIImageView()
|
||||||
|
private let iconLabel = UILabel()
|
||||||
|
|
||||||
|
init(payload: ToastPayload) {
|
||||||
|
self.payload = payload
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
accessibilityTraits = .staticText
|
||||||
|
accessibilityLabel = payload.message
|
||||||
|
|
||||||
|
configureShadow()
|
||||||
|
configureBackground()
|
||||||
|
configureContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
gradientLayer.frame = backgroundView.bounds
|
||||||
|
highlightLayer.frame = backgroundView.bounds
|
||||||
|
layer.shadowPath = UIBezierPath(
|
||||||
|
roundedRect: backgroundView.bounds,
|
||||||
|
cornerRadius: backgroundView.layer.cornerRadius
|
||||||
|
).cgPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureShadow() {
|
||||||
|
layer.shadowColor = payload.style.shadowColor.cgColor
|
||||||
|
layer.shadowOpacity = 0.32
|
||||||
|
layer.shadowRadius = 18
|
||||||
|
layer.shadowOffset = CGSize(width: 0, height: 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureBackground() {
|
||||||
|
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
backgroundView.layer.cornerRadius = 22
|
||||||
|
backgroundView.layer.masksToBounds = true
|
||||||
|
backgroundView.layer.borderWidth = 1 / UIScreen.main.scale
|
||||||
|
backgroundView.layer.borderColor = payload.style.borderColor.cgColor
|
||||||
|
|
||||||
|
addSubview(backgroundView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
backgroundView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
gradientLayer.colors = payload.style.gradientColors.map { $0.cgColor }
|
||||||
|
gradientLayer.startPoint = CGPoint(x: 0, y: 0)
|
||||||
|
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
|
||||||
|
gradientLayer.locations = [0, 1]
|
||||||
|
gradientLayer.cornerRadius = backgroundView.layer.cornerRadius
|
||||||
|
|
||||||
|
highlightLayer.backgroundColor = UIColor.white.withAlphaComponent(0.06).cgColor
|
||||||
|
highlightLayer.cornerRadius = backgroundView.layer.cornerRadius
|
||||||
|
|
||||||
|
backgroundView.layer.insertSublayer(gradientLayer, at: 0)
|
||||||
|
backgroundView.layer.insertSublayer(highlightLayer, at: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureContent() {
|
||||||
|
let contentStack = UIStackView()
|
||||||
|
contentStack.axis = .horizontal
|
||||||
|
contentStack.alignment = .center
|
||||||
|
contentStack.spacing = 14
|
||||||
|
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
backgroundView.addSubview(contentStack)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
contentStack.topAnchor.constraint(equalTo: backgroundView.topAnchor, constant: 16),
|
||||||
|
contentStack.bottomAnchor.constraint(equalTo: backgroundView.bottomAnchor, constant: -16),
|
||||||
|
contentStack.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor, constant: 20),
|
||||||
|
contentStack.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor, constant: -20)
|
||||||
|
])
|
||||||
|
|
||||||
|
if payload.style.icon.hasVisual {
|
||||||
|
let iconContainer = UIView()
|
||||||
|
iconContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
iconContainer.backgroundColor = payload.style.iconBackground
|
||||||
|
iconContainer.layer.cornerRadius = 18
|
||||||
|
iconContainer.layer.masksToBounds = false
|
||||||
|
iconContainer.layer.borderWidth = 1 / UIScreen.main.scale
|
||||||
|
iconContainer.layer.borderColor = payload.style.iconTint.withAlphaComponent(0.18).cgColor
|
||||||
|
|
||||||
|
iconImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
iconImageView.tintColor = payload.style.iconTint
|
||||||
|
iconImageView.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
|
iconLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
iconLabel.textColor = payload.style.iconTint
|
||||||
|
iconLabel.font = UIFont.systemFont(ofSize: 16, weight: .bold)
|
||||||
|
iconLabel.textAlignment = .center
|
||||||
|
|
||||||
|
iconContainer.addSubview(iconImageView)
|
||||||
|
iconContainer.addSubview(iconLabel)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
iconContainer.widthAnchor.constraint(equalToConstant: 36),
|
||||||
|
iconContainer.heightAnchor.constraint(equalToConstant: 36),
|
||||||
|
iconImageView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
|
||||||
|
iconImageView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
|
||||||
|
iconImageView.heightAnchor.constraint(equalToConstant: 20),
|
||||||
|
iconImageView.widthAnchor.constraint(equalToConstant: 20),
|
||||||
|
iconLabel.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
|
||||||
|
iconLabel.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
|
||||||
|
iconLabel.leadingAnchor.constraint(equalTo: iconContainer.leadingAnchor, constant: 2),
|
||||||
|
iconLabel.trailingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: -2)
|
||||||
|
])
|
||||||
|
|
||||||
|
switch payload.style.icon {
|
||||||
|
case .system(let name):
|
||||||
|
iconImageView.image = UIImage(systemName: name)
|
||||||
|
iconImageView.isHidden = false
|
||||||
|
iconLabel.isHidden = true
|
||||||
|
iconLabel.text = nil
|
||||||
|
case .glyph(let glyph):
|
||||||
|
iconLabel.text = glyph
|
||||||
|
iconImageView.isHidden = true
|
||||||
|
iconLabel.isHidden = false
|
||||||
|
iconImageView.image = nil
|
||||||
|
case .none:
|
||||||
|
iconImageView.isHidden = true
|
||||||
|
iconLabel.isHidden = true
|
||||||
|
iconImageView.image = nil
|
||||||
|
iconLabel.text = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStack.addArrangedSubview(iconContainer)
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageLabel = UILabel()
|
||||||
|
messageLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
messageLabel.text = payload.message
|
||||||
|
messageLabel.textColor = payload.style.textColor
|
||||||
|
messageLabel.numberOfLines = 0
|
||||||
|
messageLabel.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(
|
||||||
|
for: UIFont.systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
)
|
||||||
|
messageLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
messageLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
messageLabel.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
|
messageLabel.setContentHuggingPriority(.required, for: .vertical)
|
||||||
|
|
||||||
|
contentStack.addArrangedSubview(messageLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Palette
|
||||||
|
|
||||||
|
private enum Palette {
|
||||||
|
static let primary = UIColor(hex: 0x7A5AF8)
|
||||||
|
static let success = UIColor(hex: 0x19B36E)
|
||||||
|
static let error = UIColor(hex: 0xF95555)
|
||||||
|
static let warning = UIColor(hex: 0xFFAD42)
|
||||||
|
static let info = UIColor(hex: 0x6938EF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension UIColor {
|
||||||
|
convenience init(hex: UInt32, alpha: CGFloat = 1.0) {
|
||||||
|
let red = CGFloat((hex & 0xFF0000) >> 16) / 255.0
|
||||||
|
let green = CGFloat((hex & 0x00FF00) >> 8) / 255.0
|
||||||
|
let blue = CGFloat(hex & 0x0000FF) / 255.0
|
||||||
|
self.init(red: red, green: green, blue: blue, alpha: alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromHexString(_ hexString: String) -> UIColor? {
|
||||||
|
var string = hexString.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||||
|
|
||||||
|
if string.hasPrefix("#") {
|
||||||
|
string.removeFirst()
|
||||||
|
} else if string.hasPrefix("0X") {
|
||||||
|
string.removeFirst(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard string.count == 6 || string.count == 8 else { return nil }
|
||||||
|
|
||||||
|
var hexValue: UInt64 = 0
|
||||||
|
guard Scanner(string: string).scanHexInt64(&hexValue) else { return nil }
|
||||||
|
|
||||||
|
if string.count == 8 {
|
||||||
|
let alpha = CGFloat(hexValue & 0xFF) / 255.0
|
||||||
|
let rgb = UInt32(hexValue >> 8)
|
||||||
|
return UIColor(hex: rgb, alpha: alpha)
|
||||||
|
} else {
|
||||||
|
return UIColor(hex: UInt32(hexValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mix(with color: UIColor, amount: CGFloat) -> UIColor {
|
||||||
|
let amount = max(0, min(1, amount))
|
||||||
|
|
||||||
|
var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0
|
||||||
|
var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0
|
||||||
|
|
||||||
|
guard getRed(&r1, green: &g1, blue: &b1, alpha: &a1),
|
||||||
|
color.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) else {
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
let red = r1 * (1 - amount) + r2 * amount
|
||||||
|
let green = g1 * (1 - amount) + g2 * amount
|
||||||
|
let blue = b1 * (1 - amount) + b2 * amount
|
||||||
|
let alpha = a1 * (1 - amount) + a2 * amount
|
||||||
|
|
||||||
|
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lighten(by amount: CGFloat) -> UIColor {
|
||||||
|
mix(with: .white, amount: amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func darken(by amount: CGFloat) -> UIColor {
|
||||||
|
mix(with: .black, amount: amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var gradientPair: [UIColor] {
|
||||||
|
[lighten(by: 0.18), darken(by: 0.12)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var trimmed: String {
|
||||||
|
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,58 +1,122 @@
|
|||||||
/**
|
/**
|
||||||
* 全局Toast工具函数
|
* Global toast helper backed by native UI.
|
||||||
*
|
*
|
||||||
* 使用方式:
|
* Usage:
|
||||||
* import { Toast } from '@/utils/toast.utils';
|
* import { Toast } from '@/utils/toast.utils';
|
||||||
*
|
*
|
||||||
* Toast.success('操作成功!');
|
* Toast.success('操作成功!');
|
||||||
* Toast.error('操作失败!');
|
* Toast.error('操作失败!');
|
||||||
* Toast.warning('注意!');
|
* Toast.warning('注意!');
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ToastContextType } from '@/contexts/ToastContext';
|
import { NativeModules, Platform, ToastAndroid } from 'react-native';
|
||||||
|
|
||||||
let toastRef: ToastContextType | null = null;
|
export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
export const setToastRef = (ref: ToastContextType) => {
|
export interface ToastConfig {
|
||||||
toastRef = ref;
|
message?: string;
|
||||||
};
|
text1?: string;
|
||||||
|
duration?: number;
|
||||||
|
type?: ToastType;
|
||||||
|
backgroundColor?: string;
|
||||||
|
textColor?: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const Toast = {
|
type NativeToastModule = {
|
||||||
success: (message: string, duration?: number) => {
|
show: (options: {
|
||||||
if (toastRef) {
|
message?: string;
|
||||||
toastRef.showSuccess(message, duration);
|
text1?: string;
|
||||||
} else {
|
|
||||||
console.warn('Toast not initialized. Please wrap your app with ToastProvider');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
error: (message: string, duration?: number) => {
|
|
||||||
if (toastRef) {
|
|
||||||
toastRef.showError(message, duration);
|
|
||||||
} else {
|
|
||||||
console.warn('Toast not initialized. Please wrap your app with ToastProvider');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
warning: (message: string, duration?: number) => {
|
|
||||||
if (toastRef) {
|
|
||||||
toastRef.showWarning(message, duration);
|
|
||||||
} else {
|
|
||||||
console.warn('Toast not initialized. Please wrap your app with ToastProvider');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
show: (config: {
|
|
||||||
message: string;
|
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
type?: ToastType;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
textColor?: string;
|
textColor?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}) => {
|
}) => void;
|
||||||
if (toastRef) {
|
};
|
||||||
toastRef.showToast(config);
|
|
||||||
} else {
|
const DEFAULT_DURATION = 2000;
|
||||||
console.warn('Toast not initialized. Please wrap your app with ToastProvider');
|
const MIN_DURATION = 1000;
|
||||||
|
const MAX_DURATION = 10000;
|
||||||
|
|
||||||
|
// 增强类型安全性:添加运行时检查确保模块存在且具有预期的方法
|
||||||
|
const nativeToast = NativeModules.NativeToastManager &&
|
||||||
|
typeof NativeModules.NativeToastManager.show === 'function'
|
||||||
|
? NativeModules.NativeToastManager as NativeToastModule
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const clampDuration = (duration?: number) => {
|
||||||
|
const value = typeof duration === 'number' ? duration : DEFAULT_DURATION;
|
||||||
|
return Math.min(Math.max(value, MIN_DURATION), MAX_DURATION);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveMessage = (config: ToastConfig) => {
|
||||||
|
const raw = (config.message ?? config.text1 ?? '').toString();
|
||||||
|
return raw.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const showOnAndroid = (message: string, duration: number) => {
|
||||||
|
const toastDuration = duration >= 3000 ? ToastAndroid.LONG : ToastAndroid.SHORT;
|
||||||
|
ToastAndroid.showWithGravity(message, toastDuration, ToastAndroid.CENTER);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showNative = (config: ToastConfig) => {
|
||||||
|
const message = resolveMessage(config);
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
console.warn('Toast invoked without message content');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = clampDuration(config.duration);
|
||||||
|
const type = config.type ?? 'default';
|
||||||
|
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
showOnAndroid(message, duration);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nativeToast?.show) {
|
||||||
|
nativeToast.show({
|
||||||
|
...config,
|
||||||
|
message,
|
||||||
|
duration,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为不支持的平台提供更明显的用户反馈
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
// 在 Web 环境下使用 console.warn 并尝试使用浏览器原生 alert
|
||||||
|
console.warn(`Toast: ${message}`);
|
||||||
|
try {
|
||||||
|
// 仅在重要消息时使用 alert,避免过度打扰用户
|
||||||
|
if (config.type === 'error' || config.type === 'warning') {
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略 alert 错误
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Toast: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Toast = {
|
||||||
|
show: (config: ToastConfig) => {
|
||||||
|
showNative(config);
|
||||||
},
|
},
|
||||||
};
|
success: (message: string, duration?: number) => {
|
||||||
|
showNative({ message, duration, type: 'success' });
|
||||||
|
},
|
||||||
|
error: (message: string, duration?: number) => {
|
||||||
|
showNative({ message, duration, type: 'error' });
|
||||||
|
},
|
||||||
|
warning: (message: string, duration?: number) => {
|
||||||
|
showNative({ message, duration, type: 'warning' });
|
||||||
|
},
|
||||||
|
info: (message: string, duration?: number) => {
|
||||||
|
showNative({ message, duration, type: 'info' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user