diff --git a/components/model/MembershipModal.tsx b/components/model/MembershipModal.tsx index 886e05d..57170da 100644 --- a/components/model/MembershipModal.tsx +++ b/components/model/MembershipModal.tsx @@ -1,8 +1,6 @@ /* eslint-disable react-hooks/exhaustive-deps */ import CustomCheckBox from '@/components/ui/CheckBox'; import { USER_AGREEMENT_URL } from '@/constants/Agree'; -// import { useAuth } from '@/contexts/AuthContext'; -// import { UserApi } from '@/services'; import { log, logger } from '@/utils/logger'; import { captureMessage, @@ -275,9 +273,6 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members capturePurchaseEvent('init', '开始获取会员产品套餐'); try { - // 添加延迟,确保 RevenueCat SDK 完全初始化 - await new Promise(resolve => setTimeout(resolve, 500)); - const offerings = await Purchases.getOfferings(); 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({ 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({ message: '您已拥有此商品', }); - } else if (error.code === 'NETWORK_ERROR') { + } else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) { // 网络错误 GlobalToast.show({ message: '网络连接失败', }); - } else if (error.code === 'PAYMENT_PENDING') { + } else if (error.code === Purchases.PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) { // 支付待处理 GlobalToast.show({ message: '支付正在处理中', }); - } else if (error.code === 'INVALID_CREDENTIALS') { + } else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) { // 凭据无效 GlobalToast.show({ message: '账户验证失败', @@ -813,15 +808,15 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members 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({ message: '恢复购买已取消', }); - } else if (error.code === 'NETWORK_ERROR') { + } else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) { GlobalToast.show({ message: '网络错误', }); - } else if (error.code === 'INVALID_CREDENTIALS') { + } else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) { GlobalToast.show({ message: '账户验证失败', }); @@ -1400,7 +1395,7 @@ const styles = StyleSheet.create({ }, purchaseButton: { borderRadius: 28, - height: 52, + height: 64, justifyContent: 'center', alignItems: 'center', overflow: 'hidden', @@ -1465,7 +1460,7 @@ const styles = StyleSheet.create({ }, restoreButtonText: { color: '#6F6F7A', - fontSize: 14, + fontSize: 12, fontWeight: '500', }, disabledRestoreButton: { diff --git a/contexts/ToastContext.tsx b/contexts/ToastContext.tsx index 0859497..88ce3b1 100644 --- a/contexts/ToastContext.tsx +++ b/contexts/ToastContext.tsx @@ -1,15 +1,6 @@ -import SuccessToast from '@/components/ui/SuccessToast'; -import { Colors } from '@/constants/Colors'; -import { setToastRef } from '@/utils/toast.utils'; -import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; - -interface ToastConfig { - message: string; - duration?: number; - backgroundColor?: string; - textColor?: string; - icon?: string; -} +import { Toast, ToastConfig } from '@/utils/toast.utils'; +import React, { createContext, useContext, useMemo } from 'react'; +import { Alert, Platform } from 'react-native'; export interface ToastContextType { showToast: (config: ToastConfig) => void; @@ -21,88 +12,76 @@ export interface ToastContextType { const ToastContext = createContext(undefined); export function ToastProvider({ children }: { children: React.ReactNode }) { - const [visible, setVisible] = useState(false); - const [config, setConfig] = useState({ message: '' }); - const timeoutRef = useRef(null); - - const showToast = (toastConfig: ToastConfig) => { - // 如果已有Toast显示,先隐藏 - if (visible) { - setVisible(false); - // 短暂延迟后显示新Toast - setTimeout(() => { - setConfig(toastConfig); - setVisible(true); - }, 100); - } else { - setConfig(toastConfig); - setVisible(true); - } - }; - - const showSuccess = (message: string, duration?: number) => { - showToast({ - message, - duration, - backgroundColor: Colors.light.primary, // 主题色 - icon: '✓', - }); - }; - - const showError = (message: string, duration?: number) => { - showToast({ - message, - duration, - backgroundColor: '#f44336', // 红色 - 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]); + const value = useMemo( + () => ({ + showToast: (config: ToastConfig) => { + try { + Toast.show(config); + } catch (error) { + console.error('Toast.show failed:', error); + // 错误边界:在原生模块失败时提供回退方案 + handleToastFallback(config.message || config.text1 || '提示', config.type); + } + }, + showSuccess: (message: string, duration?: number) => { + try { + Toast.success(message, duration); + } catch (error) { + console.error('Toast.success failed:', error); + handleToastFallback(message, 'success'); + } + }, + showError: (message: string, duration?: number) => { + try { + Toast.error(message, duration); + } catch (error) { + console.error('Toast.error failed:', error); + handleToastFallback(message, 'error'); + } + }, + showWarning: (message: string, duration?: number) => { + try { + Toast.warning(message, duration); + } catch (error) { + console.error('Toast.warning failed:', error); + handleToastFallback(message, 'warning'); + } + }, + }), + [] + ); return ( {children} - ); } +/** + * 错误边界处理:在原生 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 { const context = useContext(ToastContext); if (context === undefined) { throw new Error('useToast must be used within a ToastProvider'); } return context; -} \ No newline at end of file +} diff --git a/ios/OutLive.xcodeproj/project.pbxproj b/ios/OutLive.xcodeproj/project.pbxproj index f6ac040..18f8fe0 100644 --- a/ios/OutLive.xcodeproj/project.pbxproj +++ b/ios/OutLive.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; 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 */; }; 79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; }; 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 = ""; }; 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; }; + 792C52602EB05B8F002F3F09 /* NativeToastManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = NativeToastManager.m; path = OutLive/NativeToastManager.m; sourceTree = ""; }; + 792C52612EB05B8F002F3F09 /* NativeToastManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NativeToastManager.swift; path = OutLive/NativeToastManager.swift; sourceTree = ""; }; 79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = ""; }; 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = ""; }; 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 = ""; }; @@ -94,6 +98,8 @@ 83CBB9F61A601CBA00E9B192 = { isa = PBXGroup; children = ( + 792C52602EB05B8F002F3F09 /* NativeToastManager.m */, + 792C52612EB05B8F002F3F09 /* NativeToastManager.swift */, 79B2CB712E7B954F00B51753 /* HealthKitManager.m */, 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */, 13B07FAE1A68108700A75B9A /* OutLive */, @@ -359,6 +365,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 792C52622EB05B8F002F3F09 /* NativeToastManager.m in Sources */, + 792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */, 79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */, 79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */, 79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */, diff --git a/ios/OutLive/NativeToastManager.m b/ios/OutLive/NativeToastManager.m new file mode 100644 index 0000000..ec9ea5c --- /dev/null +++ b/ios/OutLive/NativeToastManager.m @@ -0,0 +1,7 @@ +#import + +@interface RCT_EXTERN_MODULE(NativeToastManager, NSObject) + +RCT_EXTERN_METHOD(show:(NSDictionary *)options) + +@end diff --git a/ios/OutLive/NativeToastManager.swift b/ios/OutLive/NativeToastManager.swift new file mode 100644 index 0000000..cf7121f --- /dev/null +++ b/ios/OutLive/NativeToastManager.swift @@ -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) + } +} diff --git a/utils/toast.utils.ts b/utils/toast.utils.ts index a4d7303..477e98b 100644 --- a/utils/toast.utils.ts +++ b/utils/toast.utils.ts @@ -1,58 +1,122 @@ /** - * 全局Toast工具函数 - * - * 使用方式: + * Global toast helper backed by native UI. + * + * Usage: * import { Toast } from '@/utils/toast.utils'; - * + * * Toast.success('操作成功!'); * Toast.error('操作失败!'); * 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) => { - toastRef = ref; -}; +export interface ToastConfig { + message?: string; + text1?: string; + duration?: number; + type?: ToastType; + backgroundColor?: string; + textColor?: string; + icon?: string; +} -export const Toast = { - success: (message: string, duration?: number) => { - if (toastRef) { - toastRef.showSuccess(message, duration); - } 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; +type NativeToastModule = { + show: (options: { + message?: string; + text1?: string; duration?: number; + type?: ToastType; backgroundColor?: string; textColor?: string; icon?: string; - }) => { - if (toastRef) { - toastRef.showToast(config); - } else { - console.warn('Toast not initialized. Please wrap your app with ToastProvider'); + }) => void; +}; + +const DEFAULT_DURATION = 2000; +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); }, -}; \ No newline at end of file + 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' }); + }, +};