Files
袁智鸿 6314188835 prepare v1.1.4 release with native bridge and stability cleanups
- align Android/iOS native bridge implementations and audio handling paths
- improve Bluetooth provisioning/verification flow and related error handling
- refactor WebSocket, music, and device utility logic for more stable behavior
- clean up noisy debug logs and normalize comments across Flutter and native code
- update AR view, dance/agent/device pages, and platform integration details
2026-04-28 10:57:01 +08:00

393 lines
14 KiB
Swift

/*
* 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..<nextIndex])
guard let byte = UInt8(byteString, radix: 16) else { return nil }
data.append(byte)
index = nextIndex
}
return data
}
func toData() -> 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)
}
}