mirror of
https://github.com/m5stack/StackChan.git
synced 2026-04-28 03:22:39 +00:00
6314188835
- 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
393 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|