/* * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD * * SPDX-License-Identifier: MIT */ import SwiftUI import simd extension UIApplication { func endEditing() { sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } } extension Color { init?(hex: String) { var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() if hexString.hasPrefix("#") { hexString.removeFirst() } let r, g, b, a: Double switch hexString.count { case 3: // RGB let chars = Array(hexString) r = Double(strtoul(String([chars[0], chars[0]]), nil, 16)) / 255 g = Double(strtoul(String([chars[1], chars[1]]), nil, 16)) / 255 b = Double(strtoul(String([chars[2], chars[2]]), nil, 16)) / 255 a = 1.0 case 4: // RGBA let chars = Array(hexString) r = Double(strtoul(String([chars[0], chars[0]]), nil, 16)) / 255 g = Double(strtoul(String([chars[1], chars[1]]), nil, 16)) / 255 b = Double(strtoul(String([chars[2], chars[2]]), nil, 16)) / 255 a = Double(strtoul(String([chars[3], chars[3]]), nil, 16)) / 255 case 6: // RRGGBB var value: UInt64 = 0 Scanner(string: hexString).scanHexInt64(&value) r = Double((value & 0xFF0000) >> 16) / 255 g = Double((value & 0x00FF00) >> 8) / 255 b = Double(value & 0x0000FF) / 255 a = 1.0 case 8: // AARRGGBB var value: UInt64 = 0 Scanner(string: hexString).scanHexInt64(&value) a = Double((value & 0xFF000000) >> 24) / 255 r = Double((value & 0x00FF0000) >> 16) / 255 g = Double((value & 0x0000FF00) >> 8) / 255 b = Double(value & 0x000000FF) / 255 default: return nil } self.init(red: r, green: g, blue: b, opacity: a) } func toHex() -> String { let uiColor = UIColor(self) var r: CGFloat = 0 var g: CGFloat = 0 var b: CGFloat = 0 var a: CGFloat = 0 guard uiColor.getRed(&r, green: &g, blue: &b, alpha: &a) else { return "#00000000" } if a < 1.0 { return String(format: "#%02X%02X%02X%02X", Int(a * 255), Int(r * 255), Int(g * 255), Int(b * 255)) } else { return String(format: "#%02X%02X%02X", Int(r * 255), Int(g * 255), Int(b * 255)) } } } extension UIImage { func scaledToFill(_ targetSize: CGSize) -> UIImage { let renderer = UIGraphicsImageRenderer(size: targetSize) return renderer.image { _ in self.draw(in: CGRect(origin: .zero, size: targetSize)) } } /// Optional image compression method /// - Parameters: /// - resolutionSize: Target resolution (optional). If nil, no cropping or scaling is applied /// - memorySize: Target memory size in MB (optional). If nil, no memory compression is applied /// - cropCenter: Whether to crop from center. Default false = aspect-fit scaling func compress(to resolutionSize: CGSize? = nil, memorySize: Float? = nil, cropCenter: Bool = false) -> Data? { var scaledImage = self // 1. Crop or scale based on target resolution if let resolutionSize = resolutionSize { if cropCenter { // 1. Calculate scale to ensure the image fully covers the target resolution let scale = max(resolutionSize.width / size.width, resolutionSize.height / size.height) let scaledSize = CGSize(width: size.width * scale, height: size.height * scale) // 2. Calculate offset to ensure center cropping let originX = (scaledSize.width - resolutionSize.width) / 2 let originY = (scaledSize.height - resolutionSize.height) / 2 // 3. Start drawing UIGraphicsBeginImageContextWithOptions(resolutionSize, false, 1.0) self.draw(in: CGRect(x: -originX, y: -originY, width: scaledSize.width, height: scaledSize.height)) scaledImage = UIGraphicsGetImageFromCurrentImageContext() ?? self UIGraphicsEndImageContext() } else { // Aspect-fit scaling let scale = min(resolutionSize.width / size.width, resolutionSize.height / size.height) let newSize = CGSize(width: size.width * scale, height: size.height * scale) UIGraphicsBeginImageContextWithOptions(resolutionSize, false, 1.0) self.draw(in: CGRect(origin: .zero, size: newSize)) scaledImage = UIGraphicsGetImageFromCurrentImageContext() ?? self UIGraphicsEndImageContext() } } // 2. Compress based on target memory size guard let memorySize = memorySize else { return scaledImage.jpegData(compressionQuality: 1) } let maxBytes = Int(memorySize * 1024 * 1024) // MB -> Bytes var compression: CGFloat = 1.0 var imageData = scaledImage.jpegData(compressionQuality: compression) // Keep compressing until size requirement is met or compression limit is reached while let data = imageData, data.count > maxBytes, compression > 0.01 { compression *= 0.7 imageData = scaledImage.jpegData(compressionQuality: compression) } return imageData } /// Compress image only based on target memory size, keeping resolution and aspect ratio unchanged /// - Parameter memorySize: Target memory size in MB /// - Returns: Compressed JPEG data func compress(toMemorySize memorySize: Float) -> Data? { let maxBytes = Int(memorySize * 1024 * 1024) var compression: CGFloat = 1.0 var imageData = self.jpegData(compressionQuality: compression) while let data = imageData, data.count > maxBytes, compression > 0.01 { compression *= 0.7 imageData = self.jpegData(compressionQuality: compression) } return imageData } } extension String { func jsonPrint() { guard let data = self.data(using: .utf8) else { return } do { let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) let prettyData = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys]) if let prettyString = String(data: prettyData, encoding: .utf8) { } else { } } catch { } } func leftPadding(toLength: Int, withPad character: Character) -> String { if count < toLength { return String(repeatElement(character, count: toLength - count)) + self } else { return self } } /// Convert a MAC address string into 6-byte Data func macToData() -> Data? { // Remove separators such as ":" or "-" let cleaned = self.replacingOccurrences(of: "[:\\-]", with: "", options: .regularExpression) // Must be exactly 12 hexadecimal characters guard cleaned.count == 12 else { return nil } var data = Data() var index = cleaned.startIndex for _ in 0..<6 { let nextIndex = cleaned.index(index, offsetBy: 2) let byteString = String(cleaned[index.. Data? { return self.data(using: .utf8) } func toColor() -> Color { let hexString = self.trimmingCharacters(in: .whitespacesAndNewlines) if let color = Color(hex: hexString) { return color } return .clear } } extension Encodable { func toDictionary() -> [String: Any]? { guard let data = try? JSONEncoder().encode(self), let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } return dict } func toListDictionary() -> [[String: Any]]? { if let singleDict = self.toDictionary() { return [singleDict] } if let arrayData = try? JSONEncoder().encode(self), let jsonArray = try? JSONSerialization.jsonObject(with: arrayData) as? [[String: Any]] { return jsonArray } return nil } func toJsonString(prettyPrinted: Bool = false) -> String { let encoder = JSONEncoder() if prettyPrinted { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] } do { let data = try encoder.encode(self) return String(data: data, encoding: .utf8) ?? "{}" } catch { return "{}" } } func toData() -> Data? { let encoder = JSONEncoder() do { return try encoder.encode(self) } catch { return nil } } } extension UIColor { convenience init?(hex: String) { var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() if hexString.hasPrefix("#") { hexString.removeFirst() } let r, g, b, a: CGFloat switch hexString.count { case 3: // RGB let chars = Array(hexString) r = CGFloat(strtoul(String([chars[0], chars[0]]), nil, 16)) / 255 g = CGFloat(strtoul(String([chars[1], chars[1]]), nil, 16)) / 255 b = CGFloat(strtoul(String([chars[2], chars[2]]), nil, 16)) / 255 a = 1.0 case 4: // RGBA let chars = Array(hexString) r = CGFloat(strtoul(String([chars[0], chars[0]]), nil, 16)) / 255 g = CGFloat(strtoul(String([chars[1], chars[1]]), nil, 16)) / 255 b = CGFloat(strtoul(String([chars[2], chars[2]]), nil, 16)) / 255 a = CGFloat(strtoul(String([chars[3], chars[3]]), nil, 16)) / 255 case 6: // RRGGBB var value: UInt64 = 0 Scanner(string: hexString).scanHexInt64(&value) r = CGFloat((value & 0xFF0000) >> 16) / 255 g = CGFloat((value & 0x00FF00) >> 8) / 255 b = CGFloat(value & 0x0000FF) / 255 a = 1.0 case 8: // AARRGGBB var value: UInt64 = 0 Scanner(string: hexString).scanHexInt64(&value) a = CGFloat((value & 0xFF000000) >> 24) / 255 r = CGFloat((value & 0x00FF0000) >> 16) / 255 g = CGFloat((value & 0x0000FF00) >> 8) / 255 b = CGFloat(value & 0x000000FF) / 255 default: return nil } self.init(red: r, green: g, blue: b, alpha: a) } } extension CGRect { var minDimension: CGFloat { min(width, height) } } extension View { @ViewBuilder func rippleDiffusion() -> some View { TimelineView(.animation) { timeline in // Calculate time interval let time = timeline.date.timeIntervalSinceReferenceDate ZStack { // Multiple ripple cycles ForEach(0..<3) { i in let progress = (time + Double(i) * 0.5).truncatingRemainder(dividingBy: 1.5) / 1.5 Circle() .stroke(Color.blue.opacity(1 - progress), lineWidth: 2) .scaleEffect(0.5 + progress * 2) } } .drawingGroup() } } @ViewBuilder func glassEffectCircle() -> some View { if #available(iOS 26.0, *) { self.glassEffect() } else { self.background( Circle() .fill(.ultraThinMaterial) ) } } @ViewBuilder func glassEffectRegular(cornerRadius : CGFloat) -> some View { if #available(iOS 26.0, *) { self.glassEffect(.regular,in: RoundedRectangle(cornerRadius: cornerRadius)) } else { self .background( RoundedRectangle(cornerRadius: cornerRadius) .fill(.ultraThinMaterial) ) } } @ViewBuilder func presentationBackgroundClear() -> some View { if #available(iOS 26.0, *) { self.presentationBackground(.clear) } else { self.presentationBackground(.ultraThinMaterial) } } @ViewBuilder func glassButtonStyle() -> some View { if #available(iOS 26.0, *) { self.buttonStyle(.glass) } else { self } } @ViewBuilder func glassProminentButtonStyle() -> some View { if #available(iOS 26.0, *) { self.buttonStyle(.glassProminent) } else { self.buttonStyle(.borderedProminent) } } func hideKeyboard() { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } }