diff --git a/Rosetta/Assets.xcassets/TabBar/Contents.json b/Rosetta/Assets.xcassets/TabBar/Contents.json new file mode 100644 index 0000000..6e96565 --- /dev/null +++ b/Rosetta/Assets.xcassets/TabBar/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Rosetta/Assets.xcassets/TabBar/TabCalls.imageset/Contents.json b/Rosetta/Assets.xcassets/TabBar/TabCalls.imageset/Contents.json new file mode 100644 index 0000000..0c682aa --- /dev/null +++ b/Rosetta/Assets.xcassets/TabBar/TabCalls.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_tb_calls.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Rosetta/Assets.xcassets/TabBar/TabCalls.imageset/ic_tb_calls.pdf b/Rosetta/Assets.xcassets/TabBar/TabCalls.imageset/ic_tb_calls.pdf new file mode 100644 index 0000000..b8c8c2d Binary files /dev/null and b/Rosetta/Assets.xcassets/TabBar/TabCalls.imageset/ic_tb_calls.pdf differ diff --git a/Rosetta/Assets.xcassets/TabBar/TabChats.imageset/Contents.json b/Rosetta/Assets.xcassets/TabBar/TabChats.imageset/Contents.json new file mode 100644 index 0000000..07bdd22 --- /dev/null +++ b/Rosetta/Assets.xcassets/TabBar/TabChats.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_tb_chats.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Rosetta/Assets.xcassets/TabBar/TabChats.imageset/ic_tb_chats.pdf b/Rosetta/Assets.xcassets/TabBar/TabChats.imageset/ic_tb_chats.pdf new file mode 100644 index 0000000..d57b870 Binary files /dev/null and b/Rosetta/Assets.xcassets/TabBar/TabChats.imageset/ic_tb_chats.pdf differ diff --git a/Rosetta/Assets.xcassets/TabBar/TabSettings.imageset/Contents.json b/Rosetta/Assets.xcassets/TabBar/TabSettings.imageset/Contents.json new file mode 100644 index 0000000..0a0d73c --- /dev/null +++ b/Rosetta/Assets.xcassets/TabBar/TabSettings.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_tb_settings.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Rosetta/Assets.xcassets/TabBar/TabSettings.imageset/ic_tb_settings.pdf b/Rosetta/Assets.xcassets/TabBar/TabSettings.imageset/ic_tb_settings.pdf new file mode 100644 index 0000000..565bd29 Binary files /dev/null and b/Rosetta/Assets.xcassets/TabBar/TabSettings.imageset/ic_tb_settings.pdf differ diff --git a/Rosetta/DesignSystem/Components/CALayerFilters.swift b/Rosetta/DesignSystem/Components/CALayerFilters.swift new file mode 100644 index 0000000..b9fea7a --- /dev/null +++ b/Rosetta/DesignSystem/Components/CALayerFilters.swift @@ -0,0 +1,86 @@ +import UIKit + +// MARK: - CALayer Filter Helpers (Private API — same approach as Telegram iOS) + +extension CALayer { + static func blurFilter() -> NSObject? { + return makeFilter(name: "gaussianBlur") + } + + static func colorMatrixFilter() -> NSObject? { + return makeFilter(name: "colorMatrix") + } + + private static func makeFilter(name: String) -> NSObject? { + let className = ["CA", "Filter"].joined() + guard let cls = NSClassFromString(className) as? NSObject.Type else { return nil } + let sel = NSSelectorFromString("filterWithName:") + guard cls.responds(to: sel) else { return nil } + return cls.perform(sel, with: name)?.takeUnretainedValue() as? NSObject + } +} + +// MARK: - CABackdropLayer Helpers + +enum BackdropLayerHelper { + static func createBackdropLayer() -> CALayer? { + let className = ["CA", "Backdrop", "Layer"].joined() + guard let cls = NSClassFromString(className) as? CALayer.Type else { return nil } + return cls.init() + } + + static func setScale(_ layer: CALayer, scale: Double) { + let sel = NSSelectorFromString("setScale:") + guard layer.responds(to: sel) else { return } + layer.perform(sel, with: NSNumber(value: scale)) + } +} + +// MARK: - Device Capability + +enum DeviceCapability { + static let isGraphicallyCapable: Bool = { + var length = MemoryLayout.size + var cpuCount: UInt32 = 0 + sysctlbyname("hw.ncpu", &cpuCount, &length, nil, 0) + return cpuCount >= 4 + }() +} + +// MARK: - BackdropLayerDelegate (disables implicit animations) + +final class BackdropLayerDelegate: NSObject, CALayerDelegate { + static let shared = BackdropLayerDelegate() + + func action(for layer: CALayer, forKey event: String) -> CAAction? { + return NSNull() + } +} + +// MARK: - UIColor Mixing (Telegram parity) + +extension UIColor { + func mixedWith(_ other: UIColor, alpha: CGFloat) -> UIColor { + let alpha = min(1.0, max(0.0, alpha)) + let oneMinusAlpha = 1.0 - alpha + + var r1: CGFloat = 0.0 + var r2: CGFloat = 0.0 + var g1: CGFloat = 0.0 + var g2: CGFloat = 0.0 + var b1: CGFloat = 0.0 + var b2: CGFloat = 0.0 + var a1: CGFloat = 0.0 + var a2: CGFloat = 0.0 + if self.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) && + other.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) + { + let r = r1 * oneMinusAlpha + r2 * alpha + let g = g1 * oneMinusAlpha + g2 * alpha + let b = b1 * oneMinusAlpha + b2 * alpha + let a = a1 * oneMinusAlpha + a2 * alpha + return UIColor(red: r, green: g, blue: b, alpha: a) + } + return self + } +} diff --git a/Rosetta/DesignSystem/Components/GlassImageGeneration.swift b/Rosetta/DesignSystem/Components/GlassImageGeneration.swift new file mode 100644 index 0000000..5c2d745 --- /dev/null +++ b/Rosetta/DesignSystem/Components/GlassImageGeneration.swift @@ -0,0 +1,298 @@ +import UIKit + +// MARK: - Glass Image Generation (Telegram iOS parity) +// +// Port of GlassBackgroundView.generateLegacyGlassImage and generateForegroundImage +// from Telegram-iOS GlassBackgroundComponent.swift. + +enum GlassImageGeneration { + + // MARK: - Badge Path (asymmetric rounded rect) + + private static func addBadgePath(in context: CGContext, rect: CGRect) { + context.saveGState() + context.translateBy(x: rect.minX, y: rect.minY) + context.scaleBy(x: rect.width / 78.0, y: rect.height / 78.0) + + context.move(to: CGPoint(x: 0, y: 39)) + context.addCurve(to: CGPoint(x: 39, y: 0), control1: CGPoint(x: 0, y: 17.4609), control2: CGPoint(x: 17.4609, y: 0)) + context.addLine(to: CGPoint(x: 42, y: 0)) + context.addCurve(to: CGPoint(x: 78, y: 36), control1: CGPoint(x: 61.8823, y: 0), control2: CGPoint(x: 78, y: 16.1177)) + context.addLine(to: CGPoint(x: 78, y: 39)) + context.addCurve(to: CGPoint(x: 39, y: 78), control1: CGPoint(x: 78, y: 60.5391), control2: CGPoint(x: 60.5391, y: 78)) + context.addLine(to: CGPoint(x: 36, y: 78)) + context.addCurve(to: CGPoint(x: 0, y: 42), control1: CGPoint(x: 16.1177, y: 78), control2: CGPoint(x: 0, y: 61.8823)) + context.addLine(to: CGPoint(x: 0, y: 39)) + context.closePath() + + context.restoreGState() + } + + // MARK: - Legacy Glass Image + + static func generateLegacyGlassImage( + size: CGSize, + inset: CGFloat, + borderWidthFactor: CGFloat = 1.0, + isDark: Bool, + fillColor: UIColor + ) -> UIImage { + var size = size + if size == .zero { + size = CGSize(width: 2.0, height: 2.0) + } + let innerSize = size + size.width += inset * 2.0 + size.height += inset * 2.0 + + return UIGraphicsImageRenderer(size: size).image { ctx in + let context = ctx.cgContext + + context.clear(CGRect(origin: .zero, size: size)) + + let addShadow: (CGContext, Bool, CGPoint, CGFloat, CGFloat, UIColor, CGBlendMode) -> Void = { context, isOuter, position, blur, spread, shadowColor, blendMode in + var blur = blur + + if isOuter { + blur += abs(spread) + + context.beginTransparencyLayer(auxiliaryInfo: nil) + context.saveGState() + defer { + context.restoreGState() + context.endTransparencyLayer() + } + + let spreadRect = CGRect(origin: CGPoint(x: inset, y: inset), size: innerSize).insetBy(dx: 0.25, dy: 0.25) + let spreadPath = UIBezierPath( + roundedRect: spreadRect, + cornerRadius: min(spreadRect.width, spreadRect.height) * 0.5 + ).cgPath + + context.setShadow(offset: CGSize(width: position.x, height: position.y), blur: blur, color: shadowColor.cgColor) + context.setFillColor(UIColor.black.withAlphaComponent(1.0).cgColor) + context.addPath(spreadPath) + context.fillPath() + + let cleanRect = CGRect(origin: CGPoint(x: inset, y: inset), size: innerSize) + let cleanPath = UIBezierPath( + roundedRect: cleanRect, + cornerRadius: min(cleanRect.width, cleanRect.height) * 0.5 + ).cgPath + context.setBlendMode(.copy) + context.setFillColor(UIColor.clear.cgColor) + context.addPath(cleanPath) + context.fillPath() + context.setBlendMode(.normal) + } else { + let image = UIGraphicsImageRenderer(size: size).image(actions: { ctx in + let context = ctx.cgContext + + context.clear(CGRect(origin: .zero, size: size)) + let spreadRect = CGRect(origin: CGPoint(x: inset, y: inset), size: innerSize).insetBy(dx: -spread - 0.33, dy: -spread - 0.33) + + context.setShadow(offset: CGSize(width: position.x, height: position.y), blur: blur, color: shadowColor.cgColor) + context.setFillColor(shadowColor.cgColor) + let enclosingRect = spreadRect.insetBy(dx: -10000.0, dy: -10000.0) + context.addPath(UIBezierPath(rect: enclosingRect).cgPath) + addBadgePath(in: context, rect: spreadRect) + context.fillPath(using: .evenOdd) + }) + + UIGraphicsPushContext(context) + image.draw(in: CGRect(origin: .zero, size: size), blendMode: blendMode, alpha: 1.0) + UIGraphicsPopContext() + } + } + + // Outer shadows + addShadow(context, true, CGPoint(), 30.0, 0.0, UIColor(white: 0.0, alpha: 0.045), .normal) + addShadow(context, true, CGPoint(), 20.0, 0.0, UIColor(white: 0.0, alpha: 0.01), .normal) + + var a: CGFloat = 0.0 + var b: CGFloat = 0.0 + var s: CGFloat = 0.0 + fillColor.getHue(nil, saturation: &s, brightness: &b, alpha: &a) + + let innerImage = UIGraphicsImageRenderer(size: size).image { ctx in + let context = ctx.cgContext + + context.setFillColor(fillColor.cgColor) + let ellipseRect = CGRect(origin: .zero, size: size).insetBy(dx: inset, dy: inset) + context.fillEllipse(in: ellipseRect) + + let lineWidth: CGFloat = (isDark ? 0.8 : 0.8) * borderWidthFactor + let strokeColor: UIColor + let blendMode: CGBlendMode + let baseAlpha: CGFloat = isDark ? 0.3 : 0.6 + + if s == 0.0 && abs(a - 0.7) < 0.1 && !isDark { + blendMode = .normal + strokeColor = UIColor(white: 1.0, alpha: baseAlpha) + } else if s <= 0.3 && !isDark { + blendMode = .normal + strokeColor = UIColor(white: 1.0, alpha: 0.7 * baseAlpha) + } else if b >= 0.2 { + let maxAlpha: CGFloat = isDark ? 0.7 : 0.8 + blendMode = .overlay + strokeColor = UIColor(white: 1.0, alpha: max(0.5, min(1.0, maxAlpha * s)) * baseAlpha) + } else { + blendMode = .normal + strokeColor = UIColor(white: 1.0, alpha: 0.5 * baseAlpha) + } + + context.setStrokeColor(strokeColor.cgColor) + var strokeEllipseRect = CGRect(origin: .zero, size: size).insetBy(dx: inset, dy: inset) + context.addEllipse(in: strokeEllipseRect) + context.clip() + + strokeEllipseRect = CGRect(origin: .zero, size: size).insetBy(dx: inset, dy: inset).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5) + + context.setBlendMode(blendMode) + + let radius = strokeEllipseRect.height * 0.5 + let smallerRadius = radius - lineWidth * 1.33 + context.move(to: CGPoint(x: strokeEllipseRect.minX, y: strokeEllipseRect.minY + radius)) + context.addArc(tangent1End: CGPoint(x: strokeEllipseRect.minX, y: strokeEllipseRect.minY), tangent2End: CGPoint(x: strokeEllipseRect.minX + radius, y: strokeEllipseRect.minY), radius: radius) + context.addLine(to: CGPoint(x: strokeEllipseRect.maxX - smallerRadius, y: strokeEllipseRect.minY)) + context.addArc(tangent1End: CGPoint(x: strokeEllipseRect.maxX, y: strokeEllipseRect.minY), tangent2End: CGPoint(x: strokeEllipseRect.maxX, y: strokeEllipseRect.minY + smallerRadius), radius: smallerRadius) + context.addLine(to: CGPoint(x: strokeEllipseRect.maxX, y: strokeEllipseRect.maxY - radius)) + context.addArc(tangent1End: CGPoint(x: strokeEllipseRect.maxX, y: strokeEllipseRect.maxY), tangent2End: CGPoint(x: strokeEllipseRect.maxX - radius, y: strokeEllipseRect.maxY), radius: radius) + context.addLine(to: CGPoint(x: strokeEllipseRect.minX + smallerRadius, y: strokeEllipseRect.maxY)) + context.addArc(tangent1End: CGPoint(x: strokeEllipseRect.minX, y: strokeEllipseRect.maxY), tangent2End: CGPoint(x: strokeEllipseRect.minX, y: strokeEllipseRect.maxY - smallerRadius), radius: smallerRadius) + context.closePath() + context.strokePath() + + context.resetClip() + context.setBlendMode(.normal) + } + innerImage.draw(in: CGRect(origin: .zero, size: size)) + }.stretchableImage(withLeftCapWidth: Int(size.width * 0.5), topCapHeight: Int(size.height * 0.5)) + } + + // MARK: - Foreground Image (with inner shadows and gradient border) + + static func generateForegroundImage( + size: CGSize, + isDark: Bool, + fillColor: UIColor + ) -> UIImage { + var size = size + if size == .zero { + size = CGSize(width: 1.0, height: 1.0) + } + + return UIGraphicsImageRenderer(size: size).image { ctx in + let context = ctx.cgContext + context.clear(CGRect(origin: .zero, size: size)) + + let maxColor = UIColor(white: 1.0, alpha: isDark ? 0.2 : 0.9) + let minColor = UIColor(white: 1.0, alpha: 0.0) + + context.setFillColor(fillColor.cgColor) + context.fillEllipse(in: CGRect(origin: .zero, size: size)) + + let lineWidth: CGFloat = isDark ? 0.33 : 0.66 + + context.saveGState() + + // Inner shadows + let darkShadeColor = UIColor(white: isDark ? 1.0 : 0.0, alpha: isDark ? 0.0 : 0.035) + let lightShadeColor = UIColor(white: isDark ? 0.0 : 1.0, alpha: isDark ? 0.0 : 0.035) + let innerShadowBlur: CGFloat = 24.0 + + context.resetClip() + context.addEllipse(in: CGRect(origin: .zero, size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5)) + context.clip() + context.addRect(CGRect(origin: .zero, size: size).insetBy(dx: -100.0, dy: -100.0)) + context.addEllipse(in: CGRect(origin: .zero, size: size)) + context.setFillColor(UIColor.black.cgColor) + context.setShadow(offset: CGSize(width: 10.0, height: -10.0), blur: innerShadowBlur, color: darkShadeColor.cgColor) + context.fillPath(using: .evenOdd) + + context.resetClip() + context.addEllipse(in: CGRect(origin: .zero, size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5)) + context.clip() + context.addRect(CGRect(origin: .zero, size: size).insetBy(dx: -100.0, dy: -100.0)) + context.addEllipse(in: CGRect(origin: .zero, size: size)) + context.setFillColor(UIColor.black.cgColor) + context.setShadow(offset: CGSize(width: -10.0, height: 10.0), blur: innerShadowBlur, color: lightShadeColor.cgColor) + context.fillPath(using: .evenOdd) + + context.restoreGState() + + context.setLineWidth(lineWidth) + + // Left half gradient border + context.addRect(CGRect(x: 0.0, y: 0.0, width: size.width * 0.5, height: size.height)) + context.clip() + context.addEllipse(in: CGRect(origin: .zero, size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5)) + context.replacePathWithStrokedPath() + context.clip() + + do { + var locations: [CGFloat] = [0.0, 0.5, 0.5 + 0.2, 1.0 - 0.1, 1.0] + let colors: [CGColor] = [maxColor.cgColor, maxColor.cgColor, minColor.cgColor, minColor.cgColor, maxColor.cgColor] + + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! + + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) + } + + // Right half gradient border + context.resetClip() + context.addRect(CGRect(x: size.width - size.width * 0.5, y: 0.0, width: size.width * 0.5, height: size.height)) + context.clip() + context.addEllipse(in: CGRect(origin: .zero, size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5)) + context.replacePathWithStrokedPath() + context.clip() + + do { + var locations: [CGFloat] = [0.0, 0.1, 0.5 - 0.2, 0.5, 1.0] + let colors: [CGColor] = [maxColor.cgColor, minColor.cgColor, minColor.cgColor, maxColor.cgColor, maxColor.cgColor] + + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! + + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) + } + }.stretchableImage(withLeftCapWidth: Int(size.width * 0.5), topCapHeight: Int(size.height * 0.5)) + } + + // MARK: - Shadow Image + + static func generateShadowImage(cornerRadius: CGFloat) -> UIImage? { + let inset: CGFloat = 32 + let innerInset: CGFloat = 0.5 + let diameter = cornerRadius * 2 + let totalSize = CGSize(width: inset * 2 + diameter, height: inset * 2 + diameter) + + let image = UIGraphicsImageRenderer(size: totalSize).image { ctx in + let context = ctx.cgContext + context.clear(CGRect(origin: .zero, size: totalSize)) + + context.setFillColor(UIColor.black.cgColor) + context.setShadow( + offset: CGSize(width: 0, height: 1), + blur: 40.0, + color: UIColor(white: 0.0, alpha: 0.04).cgColor + ) + let ellipseRect = CGRect( + x: inset + innerInset, + y: inset + innerInset, + width: totalSize.width - (inset + innerInset) * 2, + height: totalSize.height - (inset + innerInset) * 2 + ) + context.fillEllipse(in: ellipseRect) + + context.setFillColor(UIColor.clear.cgColor) + context.setBlendMode(.copy) + context.fillEllipse(in: ellipseRect) + } + return image.stretchableImage( + withLeftCapWidth: Int(inset + cornerRadius), + topCapHeight: Int(inset + cornerRadius) + ) + } +} diff --git a/Rosetta/DesignSystem/Components/GlassMeshTransform.swift b/Rosetta/DesignSystem/Components/GlassMeshTransform.swift new file mode 100644 index 0000000..4441edc --- /dev/null +++ b/Rosetta/DesignSystem/Components/GlassMeshTransform.swift @@ -0,0 +1,557 @@ +import Foundation +import UIKit + +// MARK: - Mesh Transform Structs (matching CAMutableMeshTransform memory layout) + +struct MeshTransformMeshFace { + var indices: (UInt32, UInt32, UInt32, UInt32) + var w: (Float, Float, Float, Float) +} + +struct MeshTransformPoint3D { + var x: CGFloat + var y: CGFloat + var z: CGFloat +} + +struct MeshTransformMeshVertex { + var from: CGPoint + var to: MeshTransformPoint3D +} + +// MARK: - MeshTransform (wraps private CAMutableMeshTransform) + +final class GlassMeshTransform { + typealias Value = NSObject + typealias Point3D = MeshTransformPoint3D + typealias Vertex = MeshTransformMeshVertex + typealias Face = MeshTransformMeshFace + + private var vertices: ContiguousArray = [] + private var faces: ContiguousArray = [] + + func add(_ vertex: Vertex) { + vertices.append(vertex) + } + + func add(_ face: Face) { + faces.append(face) + } + + func makeValue() -> Value? { + let result = vertices.withUnsafeMutableBufferPointer { vertices -> NSObject? in + return faces.withUnsafeMutableBufferPointer { faces -> NSObject? in + return Self.invokeCreateCustomMethod( + vertexCount: UInt(vertices.count), + vertices: vertices.baseAddress!, + faceCount: UInt(faces.count), + faces: faces.baseAddress!, + depthNormalization: "none" + ) + } + } + if let result { + Self.invokeSetSubdivisionSteps(object: result, value: 0) + } + return result + } + + // MARK: - Private API Access + + private static let transformClass: NSObject? = { + let name = ("CAMutable" as NSString).appendingFormat("MeshTransform") + return NSClassFromString(name as String) as AnyObject as? NSObject + }() + + @inline(__always) + private static func getMethod(object: NSObject, selector: String) -> T? { + guard let method = object.method(for: NSSelectorFromString(selector)) else { return nil } + return unsafeBitCast(method, to: T.self) + } + + private static var cachedCreateCustomMethod: ((@convention(c) (AnyObject, Selector, UInt, UnsafeMutableRawPointer, UInt, UnsafeMutableRawPointer, NSString) -> NSObject?), Selector)? + + private static func invokeCreateCustomMethod( + vertexCount: UInt, + vertices: UnsafeMutablePointer, + faceCount: UInt, + faces: UnsafeMutablePointer, + depthNormalization: String + ) -> NSObject? { + guard let transformClass else { return nil } + let rawVertices = UnsafeMutableRawPointer(vertices) + let rawFaces = UnsafeMutableRawPointer(faces) + if let cached = cachedCreateCustomMethod { + return cached.0(transformClass, cached.1, vertexCount, rawVertices, faceCount, rawFaces, depthNormalization as NSString) + } + let selectorName = ("meshTransf" as NSString).appending("ormWithVertexCount:vertices:faceCount:faces:depthNormalization:") + let method: (@convention(c) (AnyObject, Selector, UInt, UnsafeMutableRawPointer, UInt, UnsafeMutableRawPointer, NSString) -> NSObject?)? = getMethod(object: transformClass, selector: selectorName) + if let method { + let selector = NSSelectorFromString(selectorName) + cachedCreateCustomMethod = (method, selector) + return method(transformClass, selector, vertexCount, rawVertices, faceCount, rawFaces, depthNormalization as NSString) + } + return nil + } + + private static var cachedSetSubdivisionSteps: ((@convention(c) (AnyObject, Selector, Int) -> Void), Selector)? + + private static func invokeSetSubdivisionSteps(object: NSObject, value: Int) { + if let cached = cachedSetSubdivisionSteps { + cached.0(object, cached.1, value) + return + } + let method: (@convention(c) (AnyObject, Selector, Int) -> Void)? = getMethod(object: object, selector: "setSubdivisionSteps:") + if let method { + let selector = NSSelectorFromString("setSubdivisionSteps:") + cachedSetSubdivisionSteps = (method, selector) + method(object, selector, value) + } + } +} + +// MARK: - Bezier Helpers + +private func a(_ a1: CGFloat, _ a2: CGFloat) -> CGFloat { + return 1.0 - 3.0 * a2 + 3.0 * a1 +} + +private func b(_ a1: CGFloat, _ a2: CGFloat) -> CGFloat { + return 3.0 * a2 - 6.0 * a1 +} + +private func c(_ a1: CGFloat) -> CGFloat { + return 3.0 * a1 +} + +private func calcBezier(_ t: CGFloat, _ a1: CGFloat, _ a2: CGFloat) -> CGFloat { + return ((a(a1, a2) * t + b(a1, a2)) * t + c(a1)) * t +} + +private func calcSlope(_ t: CGFloat, _ a1: CGFloat, _ a2: CGFloat) -> CGFloat { + return 3.0 * a(a1, a2) * t * t + 2.0 * b(a1, a2) * t + c(a1) +} + +private func getTForX(_ x: CGFloat, _ x1: CGFloat, _ x2: CGFloat) -> CGFloat { + var t = x + for _ in 0..<4 { + let currentSlope = calcSlope(t, x1, x2) + if currentSlope == 0.0 { + return t + } + let currentX = calcBezier(t, x1, x2) - x + t -= currentX / currentSlope + } + return t +} + +private func bezierPoint(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x: CGFloat) -> CGFloat { + var value = calcBezier(getTForX(x, x1, x2), y1, y2) + if value >= 0.997 { + value = 1.0 + } + return value +} + +// MARK: - Displacement Bezier + +struct DisplacementBezier { + var x1: CGFloat + var y1: CGFloat + var x2: CGFloat + var y2: CGFloat +} + +// MARK: - SDF + Displacement + +func roundedRectSDF(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, cornerRadius: CGFloat) -> CGFloat { + let px = x - width / 2 + let py = y - height / 2 + let bx = width / 2 + let by = height / 2 + let qx = abs(px) - bx + cornerRadius + let qy = abs(py) - by + cornerRadius + let outsideDist = hypot(max(qx, 0), max(qy, 0)) + let insideDist = min(max(qx, qy), 0) + return outsideDist + insideDist - cornerRadius +} + +private func roundedRectGradient(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, cornerRadius: CGFloat) -> (nx: CGFloat, ny: CGFloat) { + let px = x - width / 2 + let py = y - height / 2 + let bx = width / 2 + let by = height / 2 + let qx = abs(px) - bx + cornerRadius + let qy = abs(py) - by + cornerRadius + + var nx: CGFloat = 0 + var ny: CGFloat = 0 + + if qx > 0 && qy > 0 { + let d = hypot(qx, qy) + if d > 0 { + nx = qx / d + ny = qy / d + } + } else if qx > qy { + nx = 1 + ny = 0 + } else { + nx = 0 + ny = 1 + } + + if px < 0 { nx = -nx } + if py < 0 { ny = -ny } + + return (nx, ny) +} + +private func computeDisplacement( + x: CGFloat, y: CGFloat, + width: CGFloat, height: CGFloat, + cornerRadius: CGFloat, + edgeDistance: CGFloat, + bezier: DisplacementBezier +) -> (dx: CGFloat, dy: CGFloat, sdf: CGFloat) { + let sdf = roundedRectSDF(x: x, y: y, width: width, height: height, cornerRadius: cornerRadius) + let (nx, ny) = roundedRectGradient(x: x, y: y, width: width, height: height, cornerRadius: cornerRadius) + + let inwardX = -nx + let inwardY = -ny + let distFromEdge = -sdf + let weight = max(0, min(1, 1.0 - distFromEdge / edgeDistance)) + + var dx = inwardX * weight + var dy = inwardY * weight + + let mag = hypot(dx, dy) + if mag > 0 { + let newMag = bezierPoint(bezier.x1, bezier.y1, bezier.x2, bezier.y2, mag) + let scale = newMag / mag + dx *= scale + dy *= scale + } + + return (dx, dy, sdf) +} + +// MARK: - Mesh Template Cache + +private struct GlassMeshCacheKey: Hashable { + var cornerRadius: CGFloat + var edgeDistance: CGFloat + var cornerResolution: Int + var outerEdgeDistance: CGFloat + var bezierX1: CGFloat + var bezierY1: CGFloat + var bezierX2: CGFloat + var bezierY2: CGFloat + + init(cornerRadius: CGFloat, edgeDistance: CGFloat, cornerResolution: Int, outerEdgeDistance: CGFloat, bezier: DisplacementBezier) { + self.cornerRadius = cornerRadius + self.edgeDistance = edgeDistance + self.cornerResolution = cornerResolution + self.outerEdgeDistance = outerEdgeDistance + self.bezierX1 = bezier.x1 + self.bezierY1 = bezier.y1 + self.bezierX2 = bezier.x2 + self.bezierY2 = bezier.y2 + } +} + +private struct GlassMeshTemplate { + struct VertexTemplate { + var baseX: CGFloat + var sizeScaleX: CGFloat + var baseY: CGFloat + var sizeScaleY: CGFloat + var dispX: CGFloat + var dispY: CGFloat + var depth: CGFloat + } + + var vertices: ContiguousArray + var faces: ContiguousArray +} + +private var glassMeshTemplateCache: [GlassMeshCacheKey: GlassMeshTemplate] = [:] + +private func instantiateGlassMesh( + from template: GlassMeshTemplate, + size: CGSize, + displacementMagnitudeU: CGFloat, + displacementMagnitudeV: CGFloat +) -> GlassMeshTransform { + let W = size.width + let H = size.height + let insetPoints: CGFloat = -1.0 + let insetUOffset = insetPoints / W + let insetVOffset = insetPoints / H + let usableUNorm = (W - insetPoints * 2) / W + let usableVNorm = (H - insetPoints * 2) / H + + let transform = GlassMeshTransform() + for v in template.vertices { + let worldX = v.baseX + v.sizeScaleX * W + let worldY = v.baseY + v.sizeScaleY * H + let u = worldX / W + let vCoord = worldY / H + let mappedU = insetUOffset + u * usableUNorm + let mappedV = insetVOffset + vCoord * usableVNorm + let fromX = max(0.0, min(1.0, mappedU + v.dispX * displacementMagnitudeU)) + let fromY = max(0.0, min(1.0, mappedV + v.dispY * displacementMagnitudeV)) + transform.add(GlassMeshTransform.Vertex( + from: CGPoint(x: fromX, y: fromY), + to: GlassMeshTransform.Point3D(x: mappedU, y: mappedV, z: v.depth) + )) + } + for face in template.faces { + transform.add(face) + } + return transform +} + +private func generateGlassMeshTemplate( + cornerRadius: CGFloat, + edgeDistance: CGFloat, + cornerResolution: Int, + outerEdgeDistance: CGFloat, + bezier: DisplacementBezier +) -> GlassMeshTemplate { + let clampedRadius = cornerRadius + let refW = max(4 * clampedRadius, 100) + let refH = max(4 * clampedRadius, 100) + + var vertices = ContiguousArray() + var faces = ContiguousArray() + var vertexIndex: Int = 0 + + func templateDisplacement(worldX: CGFloat, worldY: CGFloat) -> (CGFloat, CGFloat) { + let (rawDispX, rawDispY, sdf) = computeDisplacement( + x: worldX, y: worldY, + width: refW, height: refH, + cornerRadius: clampedRadius, + edgeDistance: edgeDistance, + bezier: bezier + ) + let distToEdge = max(0.0, -sdf) + let edgeBand = max(0.0, outerEdgeDistance) + let edgeBoost: CGFloat + if edgeBand > 0 { + let t = max(0.0, min(1.0, (edgeBand - distToEdge) / edgeBand)) + edgeBoost = 1.0 + t * t * (3 - 2 * t) * 0.5 + } else { + edgeBoost = 1.0 + } + return (rawDispX * edgeBoost, rawDispY * edgeBoost) + } + + func addVertex(baseX: CGFloat, scaleX: CGFloat, baseY: CGFloat, scaleY: CGFloat, depth: CGFloat = 0) -> Int { + let worldX = baseX + scaleX * refW + let worldY = baseY + scaleY * refH + let (dispX, dispY) = templateDisplacement(worldX: worldX, worldY: worldY) + vertices.append(GlassMeshTemplate.VertexTemplate( + baseX: baseX, sizeScaleX: scaleX, + baseY: baseY, sizeScaleY: scaleY, + dispX: dispX, dispY: dispY, depth: depth + )) + let idx = vertexIndex + vertexIndex += 1 + return idx + } + + func addQuadFace(_ i0: Int, _ i1: Int, _ i2: Int, _ i3: Int) { + faces.append(GlassMeshTransform.Face( + indices: (UInt32(i0), UInt32(i1), UInt32(i2), UInt32(i3)), + w: (0.0, 0.0, 0.0, 0.0) + )) + } + + let angularStepsBase = max(3, cornerResolution) + let angularSteps = angularStepsBase % 2 == 0 ? angularStepsBase : angularStepsBase + 1 + let radialSteps = max(2, cornerResolution) + let horizontalSegments = max(2, cornerResolution / 2 + 1) + let verticalSegments = max(2, cornerResolution / 2 + 1) + let R = clampedRadius + + func depthFactorsWithOuterBand(count: Int, band: CGFloat, maxRadius: CGFloat) -> [CGFloat] { + guard count > 0, maxRadius > 0 else { return [0, 1] } + let bandNorm = max(0, min(1, band / maxRadius)) + let innerSegments = max(1, count - 1) + let innerMax = max(0, 1 - bandNorm) + var factors: [CGFloat] = (0...innerSegments).map { i in + innerMax * CGFloat(i) / CGFloat(innerSegments) + } + func appendUnique(_ value: CGFloat) { + if let last = factors.last, abs(last - value) < 1e-4 { return } + factors.append(value) + } + appendUnique(innerMax) + appendUnique(1.0) + return factors + } + + let depthFactors = depthFactorsWithOuterBand(count: radialSteps, band: outerEdgeDistance, maxRadius: R) + let angularFactors = (0...angularSteps).map { CGFloat($0) / CGFloat(angularSteps) } + let outerToInner = depthFactors.reversed() + + let topXCoeffs: [(base: CGFloat, scale: CGFloat)] = (0...horizontalSegments).map { i in + let t = CGFloat(i) / CGFloat(horizontalSegments) + return (base: R * (1 - 2 * t), scale: t) + } + let sideYCoeffs: [(base: CGFloat, scale: CGFloat)] = (0...verticalSegments).map { j in + let t = CGFloat(j) / CGFloat(verticalSegments) + return (base: R * (1 - 2 * t), scale: t) + } + let topYCoeffs: [(base: CGFloat, scale: CGFloat)] = outerToInner.map { factor in + (base: R * (1 - factor), scale: 0) + } + let bottomYCoeffs: [(base: CGFloat, scale: CGFloat)] = depthFactors.map { factor in + (base: -R * (1 - factor), scale: 1) + } + let leftXCoeffs: [(base: CGFloat, scale: CGFloat)] = outerToInner.map { factor in + (base: R * (1 - factor), scale: 0) + } + let rightXCoeffs: [(base: CGFloat, scale: CGFloat)] = depthFactors.map { factor in + (base: -R * (1 - factor), scale: 1) + } + + func buildGridTemplate( + xCoeffs: [(base: CGFloat, scale: CGFloat)], + yCoeffs: [(base: CGFloat, scale: CGFloat)] + ) { + var indexGrid: [[Int]] = [] + for yc in yCoeffs { + var row: [Int] = [] + for xc in xCoeffs { + row.append(addVertex(baseX: xc.base, scaleX: xc.scale, baseY: yc.base, scaleY: yc.scale)) + } + indexGrid.append(row) + } + let numRows = indexGrid.count - 1 + let numCols = indexGrid.first!.count - 1 + for row in 0.. 0 } + guard !ringRadials.isEmpty else { return } + + var ringIndices: [[Int]] = [] + for radial in ringRadials { + let r = R * radial + var row: [Int] = [] + for t in angularFactors { + let angle = startAngle + (endAngle - startAngle) * t + let offsetX = r * cos(angle) + let offsetY = r * sin(angle) + row.append(addVertex( + baseX: centerBaseX + offsetX, scaleX: centerScaleX, + baseY: centerBaseY + offsetY, scaleY: centerScaleY + )) + } + ringIndices.append(row) + } + + for r in 0..<(ringIndices.count - 1) { + let outerRing = ringIndices[r] + let innerRing = ringIndices[r + 1] + for i in 0..<(outerRing.count - 1) { + addQuadFace(outerRing[i], outerRing[i + 1], innerRing[i + 1], innerRing[i]) + } + } + + if let innermostRing = ringIndices.last { + let ringSegments = innermostRing.count - 1 + guard ringSegments >= 2 else { return } + + let centerAnchor = addVertex( + baseX: centerBaseX, scaleX: centerScaleX, + baseY: centerBaseY, scaleY: centerScaleY, + depth: -0.02 + ) + let stride = 2 + var i = 0 + while i + 2 <= ringSegments { + addQuadFace(centerAnchor, innermostRing[i], innermostRing[i + 1], innermostRing[i + 2]) + i += stride + } + if i < ringSegments { + addQuadFace(centerAnchor, innermostRing[ringSegments - 1], innermostRing[ringSegments], innermostRing[ringSegments]) + } + } + } + + // Edge strips + buildGridTemplate(xCoeffs: topXCoeffs, yCoeffs: topYCoeffs) + buildGridTemplate(xCoeffs: topXCoeffs, yCoeffs: bottomYCoeffs) + buildGridTemplate(xCoeffs: leftXCoeffs, yCoeffs: sideYCoeffs) + buildGridTemplate(xCoeffs: rightXCoeffs, yCoeffs: sideYCoeffs) + + // Center patch + buildGridTemplate(xCoeffs: topXCoeffs, yCoeffs: sideYCoeffs) + + // Corners + buildCornerTemplate(centerBaseX: R, centerScaleX: 0, centerBaseY: R, centerScaleY: 0, startAngle: .pi, endAngle: 1.5 * .pi) + buildCornerTemplate(centerBaseX: -R, centerScaleX: 1, centerBaseY: R, centerScaleY: 0, startAngle: 1.5 * .pi, endAngle: 2 * .pi) + buildCornerTemplate(centerBaseX: -R, centerScaleX: 1, centerBaseY: -R, centerScaleY: 1, startAngle: .pi / 2, endAngle: 0) + buildCornerTemplate(centerBaseX: R, centerScaleX: 0, centerBaseY: -R, centerScaleY: 1, startAngle: .pi, endAngle: .pi / 2) + + return GlassMeshTemplate(vertices: vertices, faces: faces) +} + +// MARK: - Public Entry Point + +func generateGlassMesh( + size: CGSize, + cornerRadius: CGFloat, + edgeDistance: CGFloat, + displacementMagnitudeU: CGFloat, + displacementMagnitudeV: CGFloat, + cornerResolution: Int, + outerEdgeDistance: CGFloat, + bezier: DisplacementBezier +) -> GlassMeshTransform { + let clampedRadius = min(cornerRadius, min(size.width, size.height) / 2) + + let key = GlassMeshCacheKey( + cornerRadius: clampedRadius, + edgeDistance: edgeDistance, + cornerResolution: cornerResolution, + outerEdgeDistance: outerEdgeDistance, + bezier: bezier + ) + let template: GlassMeshTemplate + if let cached = glassMeshTemplateCache[key] { + template = cached + } else { + template = generateGlassMeshTemplate( + cornerRadius: clampedRadius, + edgeDistance: edgeDistance, + cornerResolution: cornerResolution, + outerEdgeDistance: outerEdgeDistance, + bezier: bezier + ) + glassMeshTemplateCache[key] = template + } + return instantiateGlassMesh( + from: template, + size: size, + displacementMagnitudeU: displacementMagnitudeU, + displacementMagnitudeV: displacementMagnitudeV + ) +} diff --git a/Rosetta/DesignSystem/Components/GlassModifiers.swift b/Rosetta/DesignSystem/Components/GlassModifiers.swift index 66ccb4c..1a364ff 100644 --- a/Rosetta/DesignSystem/Components/GlassModifiers.swift +++ b/Rosetta/DesignSystem/Components/GlassModifiers.swift @@ -1,16 +1,100 @@ import SwiftUI +import UIKit + +// MARK: - Telegram-style Navigation Bar Blur (NavigationBackgroundNode parity) +// +// Telegram uses UIVisualEffectView(UIBlurEffect(.light)) with stripped filters: +// keeps only gaussianBlur + colorSaturate, removes everything else. +// This is NOT the same as Apple's .regularMaterial. + +/// UIViewRepresentable that replicates Telegram's NavigationBackgroundNode blur. +struct TelegramNavBlurView: UIViewRepresentable { + func makeUIView(context: Context) -> TelegramNavBlurUIView { + let view = TelegramNavBlurUIView(frame: .zero) + view.isUserInteractionEnabled = false + return view + } + + func updateUIView(_ uiView: TelegramNavBlurUIView, context: Context) {} +} + +/// UIKit implementation of Telegram's NavigationBackgroundNode blur. +/// Uses UIBlurEffect(.light) with only gaussianBlur + colorSaturate filters. +final class TelegramNavBlurUIView: UIView { + private let effectView: UIVisualEffectView + private let tintLayer = CALayer() + + override init(frame: CGRect) { + self.effectView = UIVisualEffectView(effect: UIBlurEffect(style: .light)) + + super.init(frame: frame) + clipsToBounds = true + isUserInteractionEnabled = false + + // Strip UIVisualEffectView subviews (same as Telegram) + for subview in effectView.subviews { + if subview.description.contains("VisualEffectSubview") { + subview.isHidden = true + } + } + + // Keep only gaussianBlur + colorSaturate filters on the backdrop sublayer + if let sublayer = effectView.layer.sublayers?[0], let filters = sublayer.filters { + sublayer.backgroundColor = nil + sublayer.isOpaque = false + let allowedKeys: [String] = ["colorSaturate", "gaussianBlur"] + sublayer.filters = filters.filter { filter in + guard let filter = filter as? NSObject else { return true } + let filterName = String(describing: filter) + return allowedKeys.contains(filterName) + } + } + + addSubview(effectView) + + // Tint overlay — Telegram applies the bar color on top of blur + tintLayer.backgroundColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(white: 0.0, alpha: 0.85) + : UIColor(white: 1.0, alpha: 0.85) + }.cgColor + layer.addSublayer(tintLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + effectView.frame = bounds + tintLayer.frame = bounds + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return } + tintLayer.backgroundColor = traitCollection.userInterfaceStyle == .dark + ? UIColor(white: 0.0, alpha: 0.85).cgColor + : UIColor(white: 1.0, alpha: 0.85).cgColor + } +} // MARK: - Glass Navigation Bar Modifier /// iOS 26+: native glassmorphism (no explicit background needed). -/// iOS < 26: frosted glass material (Telegram-style). +/// iOS < 26: Telegram-style blur (UIBlurEffect(.light) + stripped filters). struct GlassNavBarModifier: ViewModifier { func body(content: Content) -> some View { if #available(iOS 26, *) { content } else { content - .toolbarBackground(.regularMaterial, for: .navigationBar) + .toolbarBackground(.hidden, for: .navigationBar) + .background { + TelegramNavBlurView() + .ignoresSafeArea(edges: .top) + } } } } diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift index c86e660..a5a2931 100644 --- a/Rosetta/DesignSystem/Components/RosettaTabBar.swift +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -1,12 +1,11 @@ import SwiftUI import UIKit +import Lottie // MARK: - Tab enum RosettaTab: String, CaseIterable, Sendable { - case chats - case calls - case settings + case chats, calls, settings static let interactionOrder: [RosettaTab] = [.calls, .chats, .settings] @@ -18,6 +17,14 @@ enum RosettaTab: String, CaseIterable, Sendable { } } + var animationName: String { + switch self { + case .chats: return "TabChats" + case .calls: return "TabCalls" + case .settings: return "TabSettings" + } + } + var icon: String { switch self { case .chats: return "bubble.left.and.bubble.right" @@ -39,366 +46,396 @@ enum RosettaTab: String, CaseIterable, Sendable { } } -// MARK: - Tab Badge - -struct TabBadge { - let tab: RosettaTab - let text: String -} - +struct TabBadge { let tab: RosettaTab; let text: String } struct TabBarSwipeState { - let fromTab: RosettaTab - let hoveredTab: RosettaTab - let fractionalIndex: CGFloat + let fromTab: RosettaTab; let hoveredTab: RosettaTab; let fractionalIndex: CGFloat } -// MARK: - Tab Bar Colors +// MARK: - Colors -private enum TabBarColors { - static let pillBackground = RosettaColors.adaptive( - light: Color(hex: 0xF2F2F7), - dark: Color(hex: 0x2C2C2E) - ) - static let selectionBackground = RosettaColors.adaptive( - light: Color.white, - dark: Color(hex: 0x3A3A3C) - ) - static let selectedTint = Color(hex: 0x008BFF) - static let unselectedTint = RosettaColors.adaptive( - light: Color(hex: 0x3C3C43).opacity(0.6), - dark: Color.white - ) - static let pillBorder = RosettaColors.adaptive( - light: Color.black.opacity(0.08), - dark: Color.white.opacity(0.08) - ) +private enum TabBarUIColors { + static let icon = UIColor { $0.userInterfaceStyle == .dark ? .white : UIColor(red: 0.6, green: 0.6, blue: 0.6, alpha: 1) } + static let selectedIcon = UIColor(RosettaColors.primaryBlue) + static let text = UIColor { $0.userInterfaceStyle == .dark ? .white : UIColor(red: 0, green: 0, blue: 0, alpha: 0.8) } + static let selectedText = UIColor(RosettaColors.primaryBlue) + // Badge always red/white — matches Telegram screenshots in both themes + static let badgeBg = UIColor(red: 1, green: 0.23, blue: 0.19, alpha: 1) + static let badgeText = UIColor.white + static let selectionFill = UIColor.white.withAlphaComponent(0.07) } -// MARK: - Preference Keys +// MARK: - Gesture (Telegram TabSelectionRecognizer) -private struct TabWidthPreferenceKey: PreferenceKey { - static var defaultValue: [Int: CGFloat] = [:] - static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) { - value.merge(nextValue()) { $1 } +private final class TabSelectionGesture: UIGestureRecognizer { + private(set) var initialLocation: CGPoint = .zero + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + initialLocation = touches.first?.location(in: view) ?? .zero + state = .began + } + override func touchesMoved(_ touches: Set, with event: UIEvent) { + super.touchesMoved(touches, with: event) + state = .changed + } + override func touchesEnded(_ touches: Set, with event: UIEvent) { + super.touchesEnded(touches, with: event) + state = .ended + } + override func touchesCancelled(_ touches: Set, with event: UIEvent) { + super.touchesCancelled(touches, with: event) + state = .cancelled } } -private struct TabOriginPreferenceKey: PreferenceKey { - static var defaultValue: [Int: CGFloat] = [:] - static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) { - value.merge(nextValue()) { $1 } +// MARK: - RosettaTabBarUIView + +final class RosettaTabBarUIView: UIView { + + private let tabs = RosettaTab.interactionOrder + private let innerInset: CGFloat = 4 + private let itemHeight: CGFloat = 56 + private let perItemWidth: CGFloat = 90 + /// Telegram LiquidLens inset — visible gap between lens and capsule edge. + private let lensInset: CGFloat = 4 + /// Telegram: 1.15 scale when gesture active. Toned down for subtlety. + private let liftedScale: CGFloat = 1.08 + + private var barWidth: CGFloat { perItemWidth * CGFloat(tabs.count) + innerInset * 2 } + private var barHeight: CGFloat { itemHeight + innerInset * 2 } + private var itemW: CGFloat { (bounds.width - innerInset * 2) / CGFloat(tabs.count) } + + // MARK: State + + var selectedIndex: Int = 1 { + didSet { if oldValue != selectedIndex { updateSelection(animated: true) } } } -} - -// MARK: - RosettaTabBar - -struct RosettaTabBar: View { - let selectedTab: RosettaTab var onTabSelected: ((RosettaTab) -> Void)? - var onSwipeStateChanged: ((TabBarSwipeState?) -> Void)? + var badgeText: String? { didSet { layoutBadge() } } - private let allTabs = RosettaTab.interactionOrder - private let tabCount = RosettaTab.interactionOrder.count + private var isDragging = false + private var dragLensX: CGFloat = 0 + private var dragStartLensX: CGFloat = 0 + private var hoveredIndex: Int = 1 - // Drag state - @State private var isDragging = false - @State private var dragFractional: CGFloat = 0 - @State private var dragStartIndex: CGFloat = 0 + // MARK: Subviews - // Measured tab geometry - @State private var tabWidths: [Int: CGFloat] = [:] - @State private var tabOrigins: [Int: CGFloat] = [:] + private let glassBackground = TelegramGlassUIView(frame: .zero) + private let selectionView: UIView = { + let v = UIView() + v.backgroundColor = TabBarUIColors.selectionFill + v.layer.masksToBounds = true + return v + }() + private var iconViews: [LottieAnimationView] = [] + private var labelViews: [UILabel] = [] + private var badgeBgView: UIView? + private var badgeLabel: UILabel? + private var appliedColorHex: [Int: UInt64] = [:] - /// Cached badge text to avoid reading DialogRepository inside body - /// (which creates @Observable tracking and causes re-render storms during drag). - @State private var cachedBadgeText: String? + // MARK: Init - private var effectiveFractional: CGFloat { - isDragging ? dragFractional : CGFloat(selectedTab.interactionIndex) - } + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + addSubview(glassBackground) + addSubview(selectionView) - var body: some View { - // Single pill with all tabs — same structure as iOS 26 system TabView - HStack(spacing: 0) { - ForEach(Array(allTabs.enumerated()), id: \.element) { index, tab in - tabContent(tab: tab, index: index) - .background( - GeometryReader { geo in - Color.clear - .preference( - key: TabWidthPreferenceKey.self, - value: [index: geo.size.width] - ) - .preference( - key: TabOriginPreferenceKey.self, - value: [index: geo.frame(in: .named("tabBar")).minX] - ) - } - ) - } - } - .padding(4) - .coordinateSpace(name: "tabBar") - .onPreferenceChange(TabWidthPreferenceKey.self) { tabWidths = $0 } - .onPreferenceChange(TabOriginPreferenceKey.self) { tabOrigins = $0 } - .background(alignment: .leading) { - selectionIndicator - } - .background { - // TelegramGlassCapsule handles both iOS 26+ (UIGlassEffect) - // and iOS < 26 (CABackdropLayer), with isUserInteractionEnabled = false. - TelegramGlassCapsule() - } - .contentShape(Capsule()) - .gesture(dragGesture) - .modifier(TabBarShadowModifier()) - .padding(.horizontal, 25) - .padding(.top, 16) - .padding(.bottom, 12) - .onAppear { Task { @MainActor in refreshBadges() } } - .onChange(of: selectedTab) { _, _ in Task { @MainActor in refreshBadges() } } - // Observation-isolated badge refresh: checks dialogsVersion on every - // DialogRepository mutation. Only calls refreshBadges() when version changes. - .overlay { - BadgeVersionObserver(onVersionChanged: refreshBadges) - .frame(width: 0, height: 0) - .allowsHitTesting(false) - } - } - - /// Reads DialogRepository outside the body's observation scope. - private func refreshBadges() { - let repo = DialogRepository.shared - let unread = repo.sortedDialogs - .filter { !$0.isMuted } - .reduce(0) { $0 + $1.unreadCount } - if unread <= 0 { - cachedBadgeText = nil - } else { - cachedBadgeText = unread > 999 ? "\(unread / 1000)K" : "\(unread)" - } - } - - // MARK: - Selection Indicator - - @ViewBuilder - private var selectionIndicator: some View { - let frac = effectiveFractional - let nearestIdx = Int(frac.rounded()).clamped(to: 0...(tabCount - 1)) - let width = tabWidths[nearestIdx] ?? 80 - let xOffset = interpolatedOrigin(for: frac) - - Group { - if #available(iOS 26.0, *) { - // iOS 26+ — native liquid glass - Capsule().fill(.clear) - .glassEffect(.regular, in: .capsule) - .allowsHitTesting(false) - .frame(width: width) - .offset(x: xOffset) + for (i, tab) in tabs.enumerated() { + let lottie = LottieAnimationView() + lottie.contentMode = .scaleAspectFit + lottie.isUserInteractionEnabled = false + lottie.backgroundBehavior = .pauseAndRestore + if let anim = LottieAnimationCache.shared.animation(named: tab.animationName) { + lottie.animation = anim } else { - // iOS < 26 — thin frosted glass - // +2 centers the narrowed (width-4) pill within the tab - Capsule().fill(.thinMaterial) - .frame(width: width - 4) - .padding(.vertical, 4) - .offset(x: xOffset + 2) + lottie.animation = LottieAnimation.named(tab.animationName) + } + lottie.loopMode = .playOnce + lottie.currentProgress = 1.0 + addSubview(lottie) + iconViews.append(lottie) + + let label = UILabel() + label.text = tab.label + label.font = .systemFont(ofSize: 10, weight: .semibold) + label.textAlignment = .center + addSubview(label) + labelViews.append(label) + applyColor(to: i, selected: i == selectedIndex, animated: false) + } + + let g = TabSelectionGesture(target: self, action: #selector(handleGesture(_:))) + g.delegate = self + addGestureRecognizer(g) + } + + required init?(coder: NSCoder) { fatalError() } + + override var intrinsicContentSize: CGSize { CGSize(width: barWidth, height: barHeight) } + + // MARK: Layout + + override func layoutSubviews() { + super.layoutSubviews() + let w = bounds.width; let h = bounds.height + glassBackground.frame = bounds + glassBackground.updateGlass() + updateSelectionFrame(animated: false) + + let iw = itemW + for i in 0.. CGRect { + let iw = itemW + let lensH = bounds.height - lensInset * 2 + let lensW = iw + let lensX = innerInset + CGFloat(index) * iw + return CGRect(x: lensX, y: lensInset, width: lensW, height: lensH) + } + + private func updateSelectionFrame(animated: Bool) { + guard bounds.width > 0 else { return } + let lensH = bounds.height - lensInset * 2 + selectionView.layer.cornerRadius = lensH / 2 + + if isDragging { + // Lens follows finger — clamped to capsule bounds + let lensW = itemW + let minX = lensInset + let maxX = bounds.width - lensW - lensInset + let clampedX = max(minX, min(dragLensX, maxX)) + selectionView.frame = CGRect(x: clampedX, y: lensInset, width: lensW, height: lensH) + } else { + let target = lensFrame(for: selectedIndex) + if animated { + UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.78, + initialSpringVelocity: 0, options: .beginFromCurrentState) { + self.selectionView.frame = target + self.selectionView.transform = .identity + } + } else { + selectionView.frame = target + selectionView.transform = .identity } } - .animation( - isDragging ? nil : .spring(response: 0.34, dampingFraction: 0.82), - value: frac - ) } - private func interpolatedOrigin(for fractional: CGFloat) -> CGFloat { - let lower = Int(fractional).clamped(to: 0...(tabCount - 1)) - let upper = (lower + 1).clamped(to: 0...(tabCount - 1)) - let t = fractional - CGFloat(lower) - let lowerX = tabOrigins[lower] ?? 0 - let upperX = tabOrigins[upper] ?? lowerX - return lowerX + (upperX - lowerX) * t - } + // MARK: Gesture - // MARK: - Drag Gesture + @objc private func handleGesture(_ g: TabSelectionGesture) { + let iw = itemW + switch g.state { + case .began: + isDragging = true + hoveredIndex = selectedIndex + // Start lens at selected tab position + dragStartLensX = innerInset + CGFloat(selectedIndex) * iw + dragLensX = dragStartLensX - private var dragGesture: some Gesture { - DragGesture(minimumDistance: 8) - .onChanged { value in - if !isDragging { - isDragging = true - dragStartIndex = CGFloat(selectedTab.interactionIndex) - } - - let avgTabWidth = totalTabWidth / CGFloat(tabCount) - guard avgTabWidth > 0 else { return } - let delta = value.translation.width / avgTabWidth - let newFrac = (dragStartIndex - delta) - .clamped(to: 0...CGFloat(tabCount - 1)) - - dragFractional = newFrac - - let nearestIdx = Int(newFrac.rounded()).clamped(to: 0...(tabCount - 1)) - onSwipeStateChanged?(TabBarSwipeState( - fromTab: selectedTab, - hoveredTab: allTabs[nearestIdx], - fractionalIndex: newFrac - )) + // Lifted state: scale up selection indicator (Telegram isLifted) + UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.7, + initialSpringVelocity: 0, options: .beginFromCurrentState) { + self.selectionView.transform = CGAffineTransform(scaleX: self.liftedScale, y: self.liftedScale) } - .onEnded { value in - let avgTabWidth = totalTabWidth / CGFloat(tabCount) - let velocity = avgTabWidth > 0 ? value.predictedEndTranslation.width / avgTabWidth : 0 - let projected = dragFractional - velocity * 0.15 - let snappedIdx = Int(projected.rounded()).clamped(to: 0...(tabCount - 1)) - let targetTab = allTabs[snappedIdx] + updateSelectionFrame(animated: false) - isDragging = false - dragFractional = CGFloat(snappedIdx) + case .changed: + let loc = g.location(in: self) + let translation = loc.x - g.initialLocation.x + dragLensX = dragStartLensX + translation - UIImpactFeedbackGenerator(style: .light).impactOccurred() - onTabSelected?(targetTab) - onSwipeStateChanged?(nil) - } - } - - private var totalTabWidth: CGFloat { - tabWidths.values.reduce(0, +) - } - - // MARK: - Tab Content - - private func tabContent(tab: RosettaTab, index: Int) -> some View { - let frac = effectiveFractional - let distance = abs(frac - CGFloat(index)) - let blend = (1 - distance).clamped(to: 0...1) - let tint = tintColor(blend: blend) - let isEffectivelySelected = blend > 0.5 - let badge: String? = (tab == .chats) ? cachedBadgeText : nil - - return Button { - guard !isDragging else { return } - UIImpactFeedbackGenerator(style: .light).impactOccurred() - onTabSelected?(tab) - } label: { - VStack(spacing: 2) { - ZStack(alignment: .topTrailing) { - Image(systemName: isEffectivelySelected ? tab.selectedIcon : tab.icon) - .font(.system(size: 22, weight: .regular)) - .foregroundStyle(tint) - .frame(height: 28) - - if let badge { - Text(badge) - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(.white) - .padding(.horizontal, badge.count > 2 ? 4 : 0) - .frame(minWidth: 18, minHeight: 18) - .background(Capsule().fill(RosettaColors.error)) - .offset(x: 10, y: -4) + // Update hovered tab + let newHover = max(0, min(Int((loc.x - innerInset) / iw), tabs.count - 1)) + if newHover != hoveredIndex { + hoveredIndex = newHover + // Smooth color transition (Telegram: colors blend when hovering) + UIView.animate(withDuration: 0.2) { + for i in 0.. Color { - let t = blend.clamped(to: 0...1) - // Unselected tint depends on theme — compute at render time - let isDark = colorScheme == .dark - let fr: CGFloat = isDark ? 1.0 : 0.235 - let fg: CGFloat = isDark ? 1.0 : 0.235 - let fb: CGFloat = isDark ? 1.0 : 0.263 - let fa: CGFloat = isDark ? 1.0 : 0.6 - let (tr, tg, tb, ta) = Self.selectedRGBA - return Color( - red: fr + (tr - fr) * t, - green: fg + (tg - fg) * t, - blue: fb + (tb - fb) * t, - opacity: fa + (ta - fa) * t - ) + // MARK: Badge + + private func layoutBadge() { + guard let text = badgeText, !text.isEmpty else { + badgeBgView?.isHidden = true; return + } + let chatsIdx = RosettaTab.chats.interactionIndex + let iconFrame = iconViews[chatsIdx].frame + + if badgeBgView == nil { + let bg = UIView(); bg.layer.masksToBounds = true; addSubview(bg); badgeBgView = bg + let lbl = UILabel(); lbl.font = .systemFont(ofSize: 13); lbl.textAlignment = .center + bg.addSubview(lbl); badgeLabel = lbl + } + guard let bg = badgeBgView, let lbl = badgeLabel else { return } + bg.isHidden = false + bg.backgroundColor = TabBarUIColors.badgeBg + lbl.textColor = TabBarUIColors.badgeText + lbl.text = text; lbl.sizeToFit() + + let textW = lbl.frame.width + let bgW = text.count == 1 ? 18.0 : max(18.0, textW + 10) + let bgH: CGFloat = 18 + bg.frame = CGRect(x: iconFrame.maxX - 6, y: iconFrame.minY - 1, width: bgW, height: bgH) + bg.layer.cornerRadius = bgH / 2 + lbl.frame = CGRect(x: (bgW - textW) / 2, y: 0.5, width: textW, height: bgH - 1) + } + + override func traitCollectionDidChange(_ prev: UITraitCollection?) { + super.traitCollectionDidChange(prev) + if traitCollection.hasDifferentColorAppearance(comparedTo: prev) { + appliedColorHex.removeAll() + updateSelection(animated: false) + layoutBadge() + } } } -// MARK: - Shadow (iOS < 26 only) +extension RosettaTabBarUIView: UIGestureRecognizerDelegate { + func gestureRecognizer(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { false } +} + +// MARK: - SwiftUI Container + +struct RosettaTabBarContainer: View { + let selectedTab: RosettaTab + var onTabSelected: ((RosettaTab) -> Void)? + @State private var cachedBadgeText: String? + private let barWidth: CGFloat = 90 * 3 + 8 + + var body: some View { + RosettaTabBarBridge(selectedTab: selectedTab, onTabSelected: onTabSelected, badgeText: cachedBadgeText) + .frame(width: barWidth, height: 64) + .modifier(TabBarShadowModifier()) + .padding(.bottom, 8) + .onAppear { refreshBadges() } + .onChange(of: selectedTab) { _, _ in refreshBadges() } + .overlay { + BadgeVersionObserver(onVersionChanged: refreshBadges) + .frame(width: 0, height: 0).allowsHitTesting(false) + } + } + + private func refreshBadges() { + let unread = DialogRepository.shared.sortedDialogs + .filter { !$0.isMuted }.reduce(0) { $0 + $1.unreadCount } + cachedBadgeText = unread <= 0 ? nil : (unread > 999 ? "\(unread / 1000)K" : "\(unread)") + } +} + +private struct RosettaTabBarBridge: UIViewRepresentable { + let selectedTab: RosettaTab + var onTabSelected: ((RosettaTab) -> Void)? + var badgeText: String? + + func makeUIView(context: Context) -> RosettaTabBarUIView { + let v = RosettaTabBarUIView(frame: .zero) + v.selectedIndex = selectedTab.interactionIndex + v.onTabSelected = onTabSelected; v.badgeText = badgeText; return v + } + func updateUIView(_ v: RosettaTabBarUIView, context: Context) { + let idx = selectedTab.interactionIndex + if v.selectedIndex != idx { v.selectedIndex = idx } + v.onTabSelected = onTabSelected; v.badgeText = badgeText + } +} -/// Glass has built-in depth on iOS 26+, so shadow is only needed on older versions. private struct TabBarShadowModifier: ViewModifier { func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content - } else { - content - .shadow(color: Color.black.opacity(0.12), radius: 20, y: 8) - } + if #available(iOS 26.0, *) { content } + else { content.shadow(color: Color.black.opacity(0.12), radius: 20, y: 8) } } } -// MARK: - Comparable Clamping - -private extension Comparable { - func clamped(to range: ClosedRange) -> Self { - min(max(self, range.lowerBound), range.upperBound) - } -} - -// MARK: - Preview - -// MARK: - Badge Version Observer (observation-isolated) - -/// Observes DialogRepository in its own scope, debounced badge refresh. -/// Body re-evaluates on any dialog mutation (observation tracking via `dialogs` read). -/// Debounces at 500ms to avoid CPU spikes from rapid mutations. private struct BadgeVersionObserver: View { var onVersionChanged: () -> Void @State private var refreshTask: Task? @State private var lastUnread: Int = -1 - var body: some View { - // O(n) reduce — but only on THIS view's body (isolated scope). - // Not called during RosettaTabBar drag/animation. let unread = DialogRepository.shared.dialogs.values .reduce(0) { $0 + ($1.isMuted ? 0 : $1.unreadCount) } Color.clear - .onChange(of: unread) { _, newValue in - guard newValue != lastUnread else { return } - lastUnread = newValue - // Debounce to avoid rapid refreshes during sync + .onChange(of: unread) { _, v in + guard v != lastUnread else { return }; lastUnread = v refreshTask?.cancel() refreshTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(200)) - guard !Task.isCancelled else { return } - onVersionChanged() + guard !Task.isCancelled else { return }; onVersionChanged() } } - .onAppear { - lastUnread = unread - onVersionChanged() - } - } -} - -#Preview { - ZStack(alignment: .bottom) { - Color.black.ignoresSafeArea() - RosettaTabBar(selectedTab: .chats) + .onAppear { lastUnread = unread; onVersionChanged() } } } diff --git a/Rosetta/DesignSystem/Components/TabBarLottieIcon.swift b/Rosetta/DesignSystem/Components/TabBarLottieIcon.swift new file mode 100644 index 0000000..1afc23f --- /dev/null +++ b/Rosetta/DesignSystem/Components/TabBarLottieIcon.swift @@ -0,0 +1,91 @@ +import SwiftUI +import Lottie + +/// Telegram-parity tab bar icon using Lottie animations. +/// +/// Behaviour matches `TabBarComponent.ItemComponent` in Telegram-iOS: +/// - Displays the **last frame** at rest (`startingPosition: .end`) +/// - Plays **once** from start to end when the tab becomes selected +/// - Tinted with a single color via `ColorValueProvider` (Telegram: `setOverlayColor`) +struct TabBarLottieIcon: UIViewRepresentable { + let animationName: String + let color: Color + /// Incremented each time the tab becomes selected — triggers a play. + let playTrigger: Int + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator { + var lastTrigger: Int = 0 + var currentName: String = "" + var lastColorHex: UInt64 = .max // cache to skip redundant setValueProvider + } + + func makeUIView(context: Context) -> LottieAnimationView { + let view = LottieAnimationView() + view.contentMode = .scaleAspectFit + view.isUserInteractionEnabled = false + view.backgroundBehavior = .pauseAndRestore + // CRITICAL: allow SwiftUI .frame() to constrain the view. + // Without this, LottieAnimationView reports intrinsicContentSize 512×512 + // (from the JSON canvas) and SwiftUI cannot shrink it. + view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + view.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.setContentHuggingPriority(.required, for: .vertical) + view.clipsToBounds = true + loadAnimation(into: view, context: context) + return view + } + + func updateUIView(_ uiView: LottieAnimationView, context: Context) { + if context.coordinator.currentName != animationName { + loadAnimation(into: uiView, context: context) + } + + applyColorIfNeeded(uiView, coordinator: context.coordinator) + + if context.coordinator.lastTrigger != playTrigger { + context.coordinator.lastTrigger = playTrigger + uiView.play(fromProgress: 0, toProgress: 1, loopMode: .playOnce) + } + } + + private func loadAnimation(into view: LottieAnimationView, context: Context) { + if let cached = LottieAnimationCache.shared.animation(named: animationName) { + view.animation = cached + } else { + view.animation = LottieAnimation.named(animationName) + } + view.loopMode = .playOnce + view.currentProgress = 1.0 + context.coordinator.currentName = animationName + context.coordinator.lastTrigger = playTrigger + context.coordinator.lastColorHex = .max // force color apply + applyColorIfNeeded(view, coordinator: context.coordinator) + } + + private func applyColorIfNeeded(_ view: LottieAnimationView, coordinator: Coordinator) { + let hex = colorToHex(color) + guard hex != coordinator.lastColorHex else { return } + coordinator.lastColorHex = hex + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + UIColor(color).getRed(&r, green: &g, blue: &b, alpha: &a) + let lottieColor = LottieColor(r: Double(r), g: Double(g), b: Double(b), a: Double(a)) + view.setValueProvider( + ColorValueProvider(lottieColor), + keypath: AnimationKeypath(keypath: "**.Color") + ) + } + + private func colorToHex(_ c: Color) -> UInt64 { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + UIColor(c).getRed(&r, green: &g, blue: &b, alpha: &a) + let ri = UInt64(r * 255) << 24 + let gi = UInt64(g * 255) << 16 + let bi = UInt64(b * 255) << 8 + let ai = UInt64(a * 255) + return ri | gi | bi | ai + } +} diff --git a/Rosetta/DesignSystem/Components/TelegramGlassView.swift b/Rosetta/DesignSystem/Components/TelegramGlassView.swift index 951ed74..c1b18f4 100644 --- a/Rosetta/DesignSystem/Components/TelegramGlassView.swift +++ b/Rosetta/DesignSystem/Components/TelegramGlassView.swift @@ -20,10 +20,11 @@ extension EnvironmentValues { } } -// MARK: - Telegram Glass (CABackdropLayer + CAFilter) +// MARK: - Telegram Glass (CABackdropLayer + CAFilter + Mesh + Foreground Image) // // Exact port of Telegram iOS LegacyGlassView + GlassBackgroundView foreground. -// iOS < 26: CABackdropLayer with gaussianBlur radius 2.0 + dark foreground overlay. +// iOS < 26: CABackdropLayer with gaussianBlur + colorMatrix + mesh displacement +// + generated foreground image with shadows, gradient border, blend modes. // iOS 26+: native UIGlassEffect(style: .regular). /// SwiftUI wrapper for Telegram-style glass background. @@ -99,11 +100,20 @@ final class TelegramGlassUIView: UIView { // Layers private var backdropLayer: CALayer? private let clippingContainer = CALayer() - private let foregroundLayer = CALayer() + + // Foreground + Shadow image views (legacy glass) + private var foregroundImageView: UIImageView? + private var shadowImageView: UIImageView? + + // Track params for image regeneration + private var lastImageCornerRadius: CGFloat = -1 + private var lastImageIsDark: Bool? // iOS 26+ native glass private var nativeGlassView: UIVisualEffectView? + private static let shadowInset: CGFloat = 32.0 + override init(frame: CGRect) { super.init(frame: frame) clipsToBounds = false @@ -143,40 +153,56 @@ final class TelegramGlassUIView: UIView { nativeGlassView = glassView } - // MARK: - iOS < 26 (CABackdropLayer — Telegram LegacyGlassView) + // MARK: - iOS < 26 (CABackdropLayer + ColorMatrix + Mesh — Telegram parity) private func setupLegacyGlass() { - // Clipping container — holds backdrop + foreground, clips to pill shape. - // Border is added to main layer OUTSIDE the clip so it's fully visible. + // Shadow image view — positioned behind glass with negative inset + let shadow = UIImageView() + shadow.isUserInteractionEnabled = false + addSubview(shadow) + shadowImageView = shadow + + // Clipping container — holds backdrop, clips to pill shape. + // Telegram uses .circular cornerCurve for LegacyGlassView. clippingContainer.masksToBounds = true - clippingContainer.cornerCurve = .continuous + clippingContainer.cornerCurve = .circular layer.addSublayer(clippingContainer) - // 1. CABackdropLayer — blurs content behind this view - if let backdrop = Self.createBackdropLayer() { + // 1. CABackdropLayer — blurs + tones content behind this view + if let backdrop = BackdropLayerHelper.createBackdropLayer() { + backdrop.delegate = BackdropLayerDelegate.shared backdrop.rasterizationScale = 1.0 - Self.setBackdropScale(backdrop, scale: 1.0) + BackdropLayerHelper.setScale(backdrop, scale: 1.0) - // gaussianBlur filter with radius 2.0 (Telegram .normal style) - if let blurFilter = Self.makeBlurFilter() { + // Blur + Color Matrix filters (Telegram .normal style) + if let blurFilter = CALayer.blurFilter(), + let colorMatrixFilter = CALayer.colorMatrixFilter() { blurFilter.setValue(2.0 as NSNumber, forKey: "inputRadius") - backdrop.filters = [blurFilter] + + var matrix: [Float32] = [ + 2.6705, -1.1087999, -0.1117, 0.0, 0.049999997, + -0.3295, 1.8914, -0.111899994, 0.0, 0.049999997, + -0.3297, -1.1084, 2.8881, 0.0, 0.049999997, + 0.0, 0.0, 0.0, 1.0, 0.0 + ] + colorMatrixFilter.setValue( + NSValue(bytes: &matrix, objCType: "{CAColorMatrix=ffffffffffffffffffff}"), + forKey: "inputColorMatrix" + ) + colorMatrixFilter.setValue(true as NSNumber, forKey: "inputBackdropAware") + + backdrop.filters = [colorMatrixFilter, blurFilter] } clippingContainer.addSublayer(backdrop) self.backdropLayer = backdrop } - // 2. Foreground — adaptive semi-transparent fill (resolved in didMoveToWindow) - foregroundLayer.backgroundColor = UIColor(white: 0.11, alpha: 0.85).cgColor - clippingContainer.addSublayer(foregroundLayer) - - // 3. Border — on main layer via CALayer border properties. - // Using layer.borderWidth + cornerCurve ensures the border follows - // the same .continuous curve as the clipping container fill. - layer.borderWidth = 0.5 - layer.borderColor = UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor - layer.cornerCurve = .continuous + // 2. Foreground image — generated overlay with fill, shadows, gradient border + let fg = UIImageView() + fg.isUserInteractionEnabled = false + addSubview(fg) + foregroundImageView = fg } // MARK: - Layout @@ -202,7 +228,6 @@ final class TelegramGlassUIView: UIView { glassView.layer.cornerRadius = radius } else { clippingContainer.cornerRadius = radius - layer.cornerRadius = radius } } @@ -213,7 +238,6 @@ final class TelegramGlassUIView: UIView { let cornerRadius: CGFloat if let fixed = fixedCornerRadius { - // Cap at half-height to guarantee capsule shape when radius >= height/2. cornerRadius = min(fixed, bounds.height / 2) } else if isCircle { cornerRadius = min(bounds.width, bounds.height) / 2 @@ -223,116 +247,115 @@ final class TelegramGlassUIView: UIView { if #available(iOS 26.0, *), let glassView = nativeGlassView { glassView.frame = bounds - // Use cornerRadius directly — simpler and works in current iOS 26 betas. - // CAShapeLayer mask was unreliable with UIGlassEffect in some beta versions. glassView.layer.cornerRadius = cornerRadius return } - // Legacy layout — clippingContainer.masksToBounds clips all children, - // so foregroundLayer needs no cornerRadius (avoids double-rounding artifacts). + // Legacy layout clippingContainer.frame = bounds clippingContainer.cornerRadius = cornerRadius backdropLayer?.frame = bounds - foregroundLayer.frame = bounds - // Border follows .continuous curve via main layer's cornerRadius. - // clipsToBounds is false, so this only affects visual border — not child clipping. - layer.cornerRadius = cornerRadius + // Shadow image view — extends beyond bounds + let si = Self.shadowInset + shadowImageView?.frame = bounds.insetBy(dx: -si, dy: -si) + + // Foreground image view — same as shadow (image includes inset) + foregroundImageView?.frame = bounds.insetBy(dx: -si, dy: -si) + + // Apply mesh displacement (iOS 17+, capable devices) + if #available(iOS 17.0, *), DeviceCapability.isGraphicallyCapable { + let size = CGSize(width: max(1.0, bounds.width), height: max(1.0, bounds.height)) + let cr = min(min(size.width, size.height) * 0.5, cornerRadius) + let displacementMagnitudePoints: CGFloat = 20.0 + let displacementMagnitudeU = displacementMagnitudePoints / size.width + let displacementMagnitudeV = displacementMagnitudePoints / size.height + + let mesh = generateGlassMesh( + size: size, + cornerRadius: cr, + edgeDistance: min(12.0, cr), + displacementMagnitudeU: displacementMagnitudeU, + displacementMagnitudeV: displacementMagnitudeV, + cornerResolution: 12, + outerEdgeDistance: 2.0, + bezier: DisplacementBezier( + x1: 0.816137566137566, + y1: 0.20502645502645533, + x2: 0.5806878306878306, + y2: 0.873015873015873 + ) + ) + + if let meshValue = mesh.makeValue() { + backdropLayer?.setValue(meshValue, forKey: "meshTransform") + } + } + + // Regenerate foreground/shadow images if needed + regenerateGlassImages(cornerRadius: cornerRadius) + } + + // MARK: - Image Generation + + private func regenerateGlassImages(cornerRadius: CGFloat) { + let isDark = traitCollection.userInterfaceStyle == .dark + + // Skip if params unchanged + if cornerRadius == lastImageCornerRadius && isDark == lastImageIsDark { + return + } + lastImageCornerRadius = cornerRadius + lastImageIsDark = isDark + + let inset = Self.shadowInset + + // Fill color (Telegram parity) + let fillColor: UIColor + if isDark { + fillColor = UIColor(white: 1.0, alpha: 1.0).mixedWith(.black, alpha: 1.0 - 0.11).withAlphaComponent(0.85) + } else { + fillColor = UIColor(white: 1.0, alpha: 0.7) + } + + // Foreground image + let fgImage = GlassImageGeneration.generateLegacyGlassImage( + size: CGSize(width: cornerRadius * 2, height: cornerRadius * 2), + inset: inset, + borderWidthFactor: 1.0, + isDark: isDark, + fillColor: fillColor + ) + foregroundImageView?.image = fgImage + + // Shadow image + shadowImageView?.image = GlassImageGeneration.generateShadowImage(cornerRadius: cornerRadius) } // MARK: - Adaptive Colors for Legacy Glass - private func resolvedForegroundColor() -> CGColor { - let isDark = traitCollection.userInterfaceStyle == .dark - return isDark - ? UIColor(white: 0.11, alpha: 0.85).cgColor - : UIColor(white: 0.95, alpha: 0.85).cgColor - } - - private func resolvedBorderColor() -> CGColor { - let isDark = traitCollection.userInterfaceStyle == .dark - return isDark - ? UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor - : UIColor(white: 0.0, alpha: 0.08).cgColor - } - - private func updateLegacyColors() { - guard nativeGlassView == nil else { return } - foregroundLayer.backgroundColor = resolvedForegroundColor() - layer.borderColor = resolvedBorderColor() - } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return } - updateLegacyColors() + // Force image regeneration on theme change + lastImageIsDark = nil + setNeedsLayout() } override func didMoveToWindow() { super.didMoveToWindow() - // Resolve colors once view is in a window and has valid traitCollection - updateLegacyColors() + // Resolve images once view is in a window and has valid traitCollection + if nativeGlassView == nil { + lastImageIsDark = nil + setNeedsLayout() + } } - // MARK: - Shadow (drawn as separate image — Telegram parity) + // MARK: - Shadow (static helper — kept for external callers) /// Call from parent to add Telegram-exact shadow. /// Telegram: blur 40, color black 4%, offset (0,1), inset 32pt. static func makeShadowImage(cornerRadius: CGFloat) -> UIImage? { - let inset: CGFloat = 32 - let innerInset: CGFloat = 0.5 - let diameter = cornerRadius * 2 - let totalSize = CGSize(width: inset * 2 + diameter, height: inset * 2 + diameter) - - let image = UIGraphicsImageRenderer(size: totalSize).image { ctx in - let context = ctx.cgContext - context.clear(CGRect(origin: .zero, size: totalSize)) - - context.setFillColor(UIColor.black.cgColor) - context.setShadow( - offset: CGSize(width: 0, height: 1), - blur: 40.0, - color: UIColor(white: 0.0, alpha: 0.04).cgColor - ) - let ellipseRect = CGRect( - x: inset + innerInset, - y: inset + innerInset, - width: totalSize.width - (inset + innerInset) * 2, - height: totalSize.height - (inset + innerInset) * 2 - ) - context.fillEllipse(in: ellipseRect) - - // Punch out the center (shadow only, no fill) - context.setFillColor(UIColor.clear.cgColor) - context.setBlendMode(.copy) - context.fillEllipse(in: ellipseRect) - } - return image.stretchableImage( - withLeftCapWidth: Int(inset + cornerRadius), - topCapHeight: Int(inset + cornerRadius) - ) - } - - // MARK: - Private API Helpers (same approach as Telegram) - - private static func createBackdropLayer() -> CALayer? { - let className = ["CA", "Backdrop", "Layer"].joined() - guard let cls = NSClassFromString(className) as? CALayer.Type else { return nil } - return cls.init() - } - - private static func setBackdropScale(_ layer: CALayer, scale: Double) { - let sel = NSSelectorFromString("setScale:") - guard layer.responds(to: sel) else { return } - layer.perform(sel, with: NSNumber(value: scale)) - } - - private static func makeBlurFilter() -> NSObject? { - let className = ["CA", "Filter"].joined() - guard let cls = NSClassFromString(className) as? NSObject.Type else { return nil } - let sel = NSSelectorFromString("filterWithName:") - guard cls.responds(to: sel) else { return nil } - return cls.perform(sel, with: "gaussianBlur")?.takeUnretainedValue() as? NSObject + return GlassImageGeneration.generateShadowImage(cornerRadius: cornerRadius) } } diff --git a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift index f377a99..cde3f06 100644 --- a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift +++ b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift @@ -448,7 +448,8 @@ struct AttachmentPanelView: View { private var legacySelectionIndicator: some View { let width = tabWidths[selectedTab] ?? 0 let xOffset = tabOrigins[selectedTab] ?? 0 - return Capsule().fill(.thinMaterial) + return Capsule().fill(.clear) + .background { TelegramGlassCapsule() } .frame(width: max(0, width - 4)) .padding(.vertical, 4) .offset(x: xOffset + 2) diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index f58890a..8889a27 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -620,16 +620,16 @@ private extension ChatDetailView { @ToolbarContentBuilder var chatDetailToolbar: some ToolbarContent { if isMultiSelectMode { - // Selection mode toolbar (Telegram parity: Clear Chat | N Selected | Cancel) + // Selection mode toolbar (Telegram parity: all 3 items in glass capsules) ToolbarItem(placement: .navigationBarLeading) { Button { showClearChatConfirmation = true } label: { Text("Clear Chat") - .font(.system(size: 17, weight: .regular)) + .font(.system(size: 15, weight: .regular)) .foregroundStyle(RosettaColors.Adaptive.text) - .padding(.horizontal, 12) - .frame(height: 36) + .padding(.horizontal, 10) + .frame(height: 32) .background { glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } @@ -639,10 +639,10 @@ private extension ChatDetailView { ToolbarItem(placement: .principal) { Text("\(selectedMessageIds.count) Selected") - .font(.system(size: 17, weight: .semibold)) + .font(.system(size: 15, weight: .semibold)) .foregroundStyle(RosettaColors.Adaptive.text) - .padding(.horizontal, 16) - .frame(height: 36) + .padding(.horizontal, 12) + .frame(height: 32) .background { glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } @@ -654,8 +654,13 @@ private extension ChatDetailView { selectedMessageIds.removeAll() } label: { Text("Cancel") - .font(.system(size: 17, weight: .regular)) + .font(.system(size: 15, weight: .regular)) .foregroundStyle(RosettaColors.Adaptive.text) + .padding(.horizontal, 10) + .frame(height: 32) + .background { + glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) + } } .buttonStyle(.plain) } @@ -844,55 +849,55 @@ private extension ChatDetailView { @ViewBuilder private var selectionActionBar: some View { HStack(spacing: 0) { - // Delete + // Delete (Telegram: GlassButtonView + MessageSelectionTrash) Button { deleteSelectedMessages() } label: { Image(systemName: "trash") - .font(.system(size: 20)) - .foregroundStyle(selectedMessageIds.isEmpty ? RosettaColors.Adaptive.text.opacity(0.5) : .red) + .font(.system(size: 19, weight: .regular)) + .foregroundStyle(RosettaColors.Adaptive.text) .frame(width: 40, height: 40) - .background { glass(shape: .circle, strokeOpacity: 0.18, strokeColor: RosettaColors.Adaptive.text) } + .background { TelegramGlassCircle() } + .opacity(selectedMessageIds.isEmpty ? 0.5 : 1.0) } .buttonStyle(.plain) .disabled(selectedMessageIds.isEmpty) Spacer() - // Share + // Share (Telegram: GlassButtonView + MessageSelectionAction) Button { shareSelectedMessages() } label: { Image(systemName: "square.and.arrow.up") - .font(.system(size: 20)) - .foregroundStyle(selectedMessageIds.isEmpty ? RosettaColors.Adaptive.text.opacity(0.5) : RosettaColors.Adaptive.text) + .font(.system(size: 19, weight: .regular)) + .foregroundStyle(RosettaColors.Adaptive.text) .frame(width: 40, height: 40) - .background { glass(shape: .circle, strokeOpacity: 0.18, strokeColor: RosettaColors.Adaptive.text) } + .background { TelegramGlassCircle() } + .opacity(selectedMessageIds.isEmpty ? 0.5 : 1.0) } .buttonStyle(.plain) .disabled(selectedMessageIds.isEmpty) Spacer() - // Forward + // Forward (Telegram: GlassButtonView + MessageSelectionForward) Button { forwardSelectedMessages() } label: { Image(systemName: "arrowshape.turn.up.right") - .font(.system(size: 20)) - .foregroundStyle(selectedMessageIds.isEmpty ? RosettaColors.Adaptive.text.opacity(0.5) : RosettaColors.Adaptive.text) + .font(.system(size: 19, weight: .regular)) + .foregroundStyle(RosettaColors.Adaptive.text) .frame(width: 40, height: 40) - .background { glass(shape: .circle, strokeOpacity: 0.18, strokeColor: RosettaColors.Adaptive.text) } + .background { TelegramGlassCircle() } + .opacity(selectedMessageIds.isEmpty ? 0.5 : 1.0) } .buttonStyle(.plain) .disabled(selectedMessageIds.isEmpty) } - .padding(.horizontal, 26) // Telegram: 8 base + 18 safe area inset + .padding(.horizontal, 26) .padding(.vertical, 12) .padding(.bottom, 16) // safe area - .background { - glass(shape: .rounded(0), strokeOpacity: 0) - } } private func deleteSelectedMessages() { diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 58730a0..8745309 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -229,7 +229,7 @@ final class NativeMessageCell: UICollectionViewCell { private let selectionCheckContainer = UIView() private let selectionCheckBorder = CAShapeLayer() private let selectionCheckFill = CAShapeLayer() - private let selectionCheckmarkView = UIImageView() + private let selectionCheckmarkLayer = CAShapeLayer() // Swipe-to-reply private let replyCircleView = UIView() @@ -606,15 +606,16 @@ final class NativeMessageCell: UICollectionViewCell { selectionCheckContainer.isHidden = true selectionCheckContainer.isUserInteractionEnabled = false - let checkPath = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 28, height: 28)) - selectionCheckBorder.path = checkPath.cgPath + // Telegram CheckNode: overlay style — white border, shadow, CG checkmark + let inset: CGFloat = 2.0 - (1.0 / UIScreen.main.scale) // Telegram: 2.0 - UIScreenPixel + let borderWidth: CGFloat = 1.0 + (1.0 / UIScreen.main.scale) // Telegram: 1.0 + UIScreenPixel + let borderRect = CGRect(x: 0, y: 0, width: 28, height: 28).insetBy(dx: inset, dy: inset) + let checkCirclePath = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 28, height: 28)) + + selectionCheckBorder.path = UIBezierPath(ovalIn: borderRect).cgPath selectionCheckBorder.fillColor = UIColor.clear.cgColor - selectionCheckBorder.strokeColor = UIColor { traits in - traits.userInterfaceStyle == .dark - ? UIColor.white.withAlphaComponent(0.5) - : UIColor.black.withAlphaComponent(0.3) - }.cgColor - selectionCheckBorder.lineWidth = 1.5 // Telegram: 1.0 + UIScreenPixel + selectionCheckBorder.strokeColor = UIColor.white.cgColor // Telegram: pure white for overlay + selectionCheckBorder.lineWidth = borderWidth selectionCheckContainer.layer.addSublayer(selectionCheckBorder) // Telegram CheckNode overlay shadow @@ -623,18 +624,30 @@ final class NativeMessageCell: UICollectionViewCell { selectionCheckContainer.layer.shadowRadius = 2.5 selectionCheckContainer.layer.shadowOffset = .zero - selectionCheckFill.path = checkPath.cgPath - selectionCheckFill.fillColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1).cgColor // #248AE6 + selectionCheckFill.path = checkCirclePath.cgPath + selectionCheckFill.fillColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1).cgColor // #248AE6 — Rosetta primary blue selectionCheckFill.isHidden = true selectionCheckContainer.layer.addSublayer(selectionCheckFill) - let checkConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) - selectionCheckmarkView.image = UIImage(systemName: "checkmark", withConfiguration: checkConfig) - selectionCheckmarkView.tintColor = .white - selectionCheckmarkView.contentMode = .center - selectionCheckmarkView.frame = CGRect(x: 0, y: 0, width: 28, height: 28) - selectionCheckmarkView.isHidden = true - selectionCheckContainer.addSubview(selectionCheckmarkView) + // Telegram CheckNode: CG-drawn checkmark (1.5pt, round cap/join) + let s = (28.0 - inset * 2) / 18.0 // Telegram scale factor + let cx = 14.0, cy = 14.0 + let startX = cx - (4.0 - 0.3333) * s + let startY = cy + 0.5 * s + let checkmarkPath = UIBezierPath() + checkmarkPath.move(to: CGPoint(x: startX, y: startY)) + checkmarkPath.addLine(to: CGPoint(x: startX + 2.5 * s, y: startY + 3.0 * s)) + checkmarkPath.addLine(to: CGPoint(x: startX + 2.5 * s + 4.6667 * s, y: startY + 3.0 * s - 6.0 * s)) + + selectionCheckmarkLayer.path = checkmarkPath.cgPath + selectionCheckmarkLayer.strokeColor = UIColor.white.cgColor + selectionCheckmarkLayer.fillColor = UIColor.clear.cgColor + selectionCheckmarkLayer.lineWidth = 1.5 + selectionCheckmarkLayer.lineCap = .round + selectionCheckmarkLayer.lineJoin = .round + selectionCheckmarkLayer.frame = CGRect(x: 0, y: 0, width: 28, height: 28) + selectionCheckmarkLayer.isHidden = true + selectionCheckContainer.layer.addSublayer(selectionCheckmarkLayer) contentView.addSubview(selectionCheckContainer) @@ -2708,7 +2721,7 @@ final class NativeMessageCell: UICollectionViewCell { // Selection: reset selected state on reuse, keep mode (same for all cells) isMessageSelected = false selectionCheckFill.isHidden = true - selectionCheckmarkView.isHidden = true + selectionCheckmarkLayer.isHidden = true } // MARK: - Multi-Select @@ -2717,8 +2730,6 @@ final class NativeMessageCell: UICollectionViewCell { guard isInSelectionMode != enabled else { return } isInSelectionMode = enabled let newOffset: CGFloat = enabled ? 42 : 0 - let duration: TimeInterval = enabled ? 0.3 : 0.4 - let damping: CGFloat = enabled ? 0.8 : 0.85 if animated { selectionCheckContainer.isHidden = false @@ -2727,11 +2738,11 @@ final class NativeMessageCell: UICollectionViewCell { let slideFrom = enabled ? -42.0 : 0.0 let slideTo = enabled ? 0.0 : -42.0 - // Checkbox fade + slide + // Telegram: 0.2s easeOut for checkbox fade + slide let alphaAnim = CABasicAnimation(keyPath: "opacity") alphaAnim.fromValue = fromAlpha alphaAnim.toValue = toAlpha - alphaAnim.duration = duration + alphaAnim.duration = 0.2 alphaAnim.timingFunction = CAMediaTimingFunction(name: .easeOut) alphaAnim.fillMode = .forwards alphaAnim.isRemovedOnCompletion = false @@ -2740,15 +2751,15 @@ final class NativeMessageCell: UICollectionViewCell { let posAnim = CABasicAnimation(keyPath: "position.x") posAnim.fromValue = selectionCheckContainer.layer.position.x + slideFrom posAnim.toValue = selectionCheckContainer.layer.position.x + slideTo - posAnim.duration = duration + posAnim.duration = 0.2 posAnim.timingFunction = CAMediaTimingFunction(name: .easeOut) selectionCheckContainer.layer.add(posAnim, forKey: "selectionSlide") selectionCheckContainer.layer.opacity = toAlpha - // Content shift (spring animation, Telegram parity) + // Telegram: 0.2s easeOut for content shift selectionOffset = newOffset - UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: damping, initialSpringVelocity: 0, options: []) { + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) { self.setNeedsLayout() self.layoutIfNeeded() } completion: { _ in @@ -2771,7 +2782,7 @@ final class NativeMessageCell: UICollectionViewCell { guard isMessageSelected != selected else { return } isMessageSelected = selected selectionCheckFill.isHidden = !selected - selectionCheckmarkView.isHidden = !selected + selectionCheckmarkLayer.isHidden = !selected selectionCheckBorder.isHidden = selected if animated && selected { @@ -2782,7 +2793,7 @@ final class NativeMessageCell: UICollectionViewCell { anim.duration = 0.21 anim.timingFunction = CAMediaTimingFunction(name: .easeOut) selectionCheckFill.add(anim, forKey: "checkBounce") - selectionCheckmarkView.layer.add(anim, forKey: "checkBounce") + selectionCheckmarkLayer.add(anim, forKey: "checkBounce") } else if animated && !selected { // Telegram CheckNode: 2-stage scale 1→0.9→1 over 0.15s let anim = CAKeyframeAnimation(keyPath: "transform.scale") diff --git a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift index e0afc0a..ffe0e0e 100644 --- a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift +++ b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift @@ -215,38 +215,38 @@ struct OpponentProfileView: View { // MARK: - Shared Media Tab Bar (Telegram parity) private var tabActiveColor: Color { colorScheme == .dark ? .white : .black } - private var tabInactiveColor: Color { colorScheme == .dark ? Color.white.opacity(0.6) : Color.black.opacity(0.4) } + private var tabInactiveColor: Color { colorScheme == .dark ? Color(red: 0x98/255, green: 0x98/255, blue: 0x9E/255) : Color.black.opacity(0.4) } private var tabIndicatorFill: Color { colorScheme == .dark ? Color.white.opacity(0.18) : Color.black.opacity(0.08) } private var sharedMediaTabBar: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - ForEach(PeerProfileTab.allCases, id: \.self) { tab in - Button { - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { - selectedTab = tab - } - } label: { - Text(tab.rawValue) - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(selectedTab == tab ? tabActiveColor : tabInactiveColor) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background { - if selectedTab == tab { - Capsule() - .fill(tabIndicatorFill) - .matchedGeometryEffect(id: "peer_tab", in: tabNamespace) - } - } + HStack(spacing: 0) { + ForEach(PeerProfileTab.allCases, id: \.self) { tab in + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + selectedTab = tab } - .buttonStyle(.plain) + } label: { + Text(tab.rawValue) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(selectedTab == tab ? tabActiveColor : tabInactiveColor) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background { + if selectedTab == tab { + Capsule() + .fill(tabIndicatorFill) + .matchedGeometryEffect(id: "peer_tab", in: tabNamespace) + } + } } + .buttonStyle(.plain) } - .padding(.horizontal, 3) - .padding(.vertical, 3) } - .background(Capsule().fill(telegramSectionFill)) + .padding(.horizontal, 3) + .padding(.vertical, 3) + .background { + TelegramGlassCapsule() + } .padding(.horizontal, 16) } @@ -462,7 +462,7 @@ private struct IOS18ScrollTracker: View { .onScrollGeometryChange(for: CGFloat.self) { $0.contentInsets.top } action: { _, v in topInset = v } .onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y + $0.contentInsets.top } action: { _, v in if scrollPhase == .interacting { - withAnimation(.snappy(duration: 0.2, extraBounce: 0)) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.86)) { isLargeHeader = canExpand && (v < -10 || (isLargeHeader && v < 0)) } } diff --git a/Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift b/Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift index 2aa1447..c9835b6 100644 --- a/Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift +++ b/Rosetta/Features/Chats/ChatDetail/PeerProfileHeaderView.swift @@ -74,11 +74,13 @@ struct PeerProfileHeaderView: View { .scaledToFill() } else { let pair = RosettaColors.avatarColors[avatarColorIndex % RosettaColors.avatarColors.count] + let textColor: Color = colorScheme == .dark ? pair.text : pair.tint ZStack { - Rectangle().fill(pair.tint) + Rectangle().fill(colorScheme == .dark ? Color(hex: 0x1A1B1E) : .white) + Rectangle().fill(pair.tint.opacity(colorScheme == .dark ? 0.15 : 0.10)) Text(avatarInitials) - .font(.system(size: isLargeHeader ? 120 : 36, weight: .medium)) - .foregroundStyle(pair.text) + .font(.system(size: isLargeHeader ? 120 : 38, weight: .bold, design: .rounded)) + .foregroundStyle(textColor) } } } @@ -86,38 +88,34 @@ struct PeerProfileHeaderView: View { // MARK: - Navigation Bar Content (sticky + scale) private var navigationBarContent: some View { - VStack(alignment: isLargeHeader ? .leading : .center, spacing: 4) { + VStack(alignment: isLargeHeader ? .leading : .center, spacing: isLargeHeader ? 4 : 1) { HStack(spacing: 5) { Text(displayName) - .font(.title2) - .fontWeight(.semibold) - .lineLimit(2) + .font(isLargeHeader ? .title2.weight(.semibold) : .system(size: 17, weight: .semibold)) + .lineLimit(isLargeHeader ? 2 : 1) if effectiveVerified > 0 { VerifiedBadge( verified: effectiveVerified, - size: 18, + size: isLargeHeader ? 18 : 15, badgeTint: isLargeHeader ? .white : nil ) } } Text(subtitleText) - .font(.callout) + .font(isLargeHeader ? .callout : .system(size: 16)) .foregroundStyle(isLargeHeader ? .white.opacity(0.7) : .secondary) } .frame(maxWidth: .infinity, alignment: isLargeHeader ? .leading : .center) .visualEffect { content, proxy in let minY = proxy.frame(in: .scrollView(axis: .vertical)).minY let progress = max(min(minY / 50, 1), 0) - let scale = 0.7 + (0.3 * progress) - let scaledH = proxy.size.height * scale - // Center title at nav bar vertical center when stuck - let navBarCenterY = topInset - 22 - let centeringOffset = navBarCenterY - scaledH / 2 + let contentH = proxy.size.height + let navBarCenterY: CGFloat = -22 + let centeringOffset = navBarCenterY - contentH / 2 return content - .scaleEffect(scale, anchor: .top) .offset(y: minY < 0 ? -minY + centeringOffset * (1 - progress) : 0) } .background { navBarBackground } @@ -130,32 +128,27 @@ struct PeerProfileHeaderView: View { GeometryReader { geo in let minY = geo.frame(in: .scrollView(axis: .vertical)).minY let opacity = 1.0 - max(min(minY / 50, 1), 0) - let tint: Color = colorScheme == .dark ? .black : .white - ZStack { - if #available(iOS 26, *) { - Rectangle() - .fill(.clear) - .glassEffect(.clear.tint(tint.opacity(0.8)), in: .rect) - .mask { - LinearGradient( - colors: [.black, .black, .black, .black.opacity(0.5), .clear], - startPoint: .top, - endPoint: .bottom - ) - } - } else { - Rectangle() - .fill(tint) - .mask { - LinearGradient( - colors: [.black, .black, .black, .black.opacity(0.9), .black.opacity(0.4), .clear], - startPoint: .top, - endPoint: .bottom - ) - } + // Telegram parity: EdgeEffectView(content: #000000, blur: true, edgeSize: 60) + // 89-stop smooth gradient from opaque black to transparent + Rectangle() + .fill(Color.black) + .mask { + LinearGradient( + stops: [ + .init(color: .black, location: 0.0), + .init(color: .black, location: 0.45), + .init(color: .black.opacity(0.9), location: 0.55), + .init(color: .black.opacity(0.75), location: 0.65), + .init(color: .black.opacity(0.55), location: 0.75), + .init(color: .black.opacity(0.3), location: 0.85), + .init(color: .black.opacity(0.1), location: 0.93), + .init(color: .clear, location: 1.0), + ], + startPoint: .top, + endPoint: .bottom + ) } - } .padding(-20) .padding(.bottom, -40) .padding(.top, -topInset) @@ -200,10 +193,8 @@ struct PeerProfileHeaderView: View { .fill(telegramSectionFill) .opacity(isLargeHeader ? 0 : 1) - RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(.ultraThinMaterial) + TelegramGlassRoundedRect(cornerRadius: 15) .opacity(isLargeHeader ? 0.8 : 0) - .environment(\.colorScheme, .dark) } } .contentShape(.rect) diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index 3b6036a..4b68bc2 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -152,22 +152,10 @@ struct MainTabView: View { .ignoresSafeArea() if !isChatSearchActive && !isAnyChatDetailPresented && !isSettingsEditPresented && !isSettingsDetailPresented { - RosettaTabBar( + RosettaTabBarContainer( selectedTab: selectedTab, onTabSelected: { tab in selectedTab = tab - }, - onSwipeStateChanged: { state in - if let state { - for tab in RosettaTab.interactionOrder { - activatedTabs.insert(tab) - } - dragFractionalIndex = state.fractionalIndex - } else { - withAnimation(.easeInOut(duration: 0.15)) { - dragFractionalIndex = nil - } - } } ) .ignoresSafeArea(.keyboard) @@ -186,35 +174,34 @@ struct MainTabView: View { CGFloat(selectedTab.interactionIndex) } + // MARK: - Tab Pager (Telegram Parity) + // + // Telegram TabBarController animation: + // New tab: alpha 0→1 (0.1s) + scale 0.998→1.0 (0.15s, delay 0.1s, spring) + // Old tab: just removed (no fade-out) + // + // zIndex ensures selected tab is always on top, so only the fade-IN + // is visible — the old tab's opacity change is hidden underneath. + @ViewBuilder private func tabPager(availableSize: CGSize) -> some View { let width = max(1, availableSize.width) ZStack { ForEach(RosettaTab.interactionOrder, id: \.self) { tab in + let isSelected = tab == selectedTab tabView(for: tab) .frame(width: width, height: availableSize.height) - .opacity(tabOpacity(for: tab)) - .environment(\.telegramGlassActive, tabOpacity(for: tab) > 0) + .zIndex(isSelected ? 1 : 0) + .opacity(isSelected ? 1 : 0) + .environment(\.telegramGlassActive, isSelected) .animation(.easeOut(duration: 0.12), value: selectedTab) - .allowsHitTesting(tab == selectedTab && dragFractionalIndex == nil) + .allowsHitTesting(isSelected) } } .clipped() } - private func tabOpacity(for tab: RosettaTab) -> Double { - if let frac = dragFractionalIndex { - // During drag: crossfade between adjacent tabs - let tabIndex = CGFloat(tab.interactionIndex) - let distance = abs(frac - tabIndex) - if distance >= 1 { return 0 } - return Double(1 - distance) - } else { - return tab == selectedTab ? 1 : 0 - } - } - @ViewBuilder private func tabView(for tab: RosettaTab) -> some View { if activatedTabs.contains(tab) { diff --git a/Rosetta/Resources/Lottie/TabCalls.json b/Rosetta/Resources/Lottie/TabCalls.json new file mode 100644 index 0000000..ce40181 --- /dev/null +++ b/Rosetta/Resources/Lottie/TabCalls.json @@ -0,0 +1 @@ +{"v":"4.8.0","meta":{"g":"LottieFiles AE ","a":"","k":"","d":"","tc":""},"fr":60,"ip":0,"op":42,"w":512,"h":512,"nm":"Calls 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"Null 1","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[266,248,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[80,80,100],"ix":6}},"ao":0,"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Wave 1","parent":1,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":29,"s":[100]},{"t":38,"s":[10]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.2,"y":0},"t":16,"s":[2.339,-2.355,0],"to":[0,0,0],"ti":[0,0,0]},{"t":39,"s":[45.839,-45.855,0]}],"ix":2},"a":{"a":0,"k":[43.339,-55.355,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.2,"y":0},"t":16,"s":[{"i":[[-5.533,0.024],[-0.032,-37.225]],"o":[[17.452,-0.075],[0.004,4.811]],"v":[[20.548,-80.425],[68.532,-30.775]],"c":false}]},{"t":39,"s":[{"i":[[-5.325,-0.175],[-0.909,-28.527]],"o":[[25.011,0.822],[0.148,4.631]],"v":[[19.364,-78.447],[66.534,-31.598]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":15,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":19,"op":39,"st":16,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Wave 2","parent":1,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25,"s":[100]},{"t":34,"s":[10]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.2,"y":0},"t":10,"s":[12.195,-11.177,0],"to":[0,0,0],"ti":[0,0,0]},{"t":34,"s":[65.195,-66.177,0]}],"ix":2},"a":{"a":0,"k":[60.195,-73.177,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.2,"y":0},"t":10,"s":[{"i":[[0,0],[0.624,-27.851]],"o":[[30.962,0.129],[-0.035,1.579]],"v":[[34.538,-99.129],[86.483,-48.079]],"c":false}]},{"t":34,"s":[{"i":[[0,0],[1.283,-57.259]],"o":[[66.449,-5.733],[-0.073,3.246]],"v":[[6.784,-125.442],[113.578,-20.488]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":15,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":12,"op":35,"st":10,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Wave 3","parent":1,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[100]},{"t":29,"s":[10]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.2,"y":0},"t":4,"s":[13.436,-15.049,0],"to":[0,0,0],"ti":[0,0,0]},{"t":29,"s":[87.436,-87.049,0]}],"ix":2},"a":{"a":0,"k":[82.436,-94.049,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.2,"y":0},"t":4,"s":[{"i":[[0,0],[0.307,-25.5]],"o":[[26.737,-0.438],[0,0]],"v":[[60.763,-121.062],[108.693,-74]],"c":false}]},{"t":29,"s":[{"i":[[0,0],[7.65,-85.061]],"o":[[98.311,-12.031],[0,0]],"v":[[6.336,-167.822],[157.889,-19.014]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":15,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":30,"st":4,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Phone","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.28],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.72],"y":[1]},"o":{"x":[0.28],"y":[0]},"t":9,"s":[-11]},{"i":{"x":[0.72],"y":[1]},"o":{"x":[0.28],"y":[0]},"t":17,"s":[10]},{"i":{"x":[0.72],"y":[1]},"o":{"x":[0.28],"y":[0]},"t":24,"s":[-6]},{"i":{"x":[0.72],"y":[1]},"o":{"x":[0.28],"y":[0]},"t":30,"s":[4]},{"i":{"x":[0.72],"y":[1]},"o":{"x":[0.28],"y":[0]},"t":36,"s":[-2]},{"t":41,"s":[0]}],"ix":10},"p":{"a":0,"k":[256.05,256.05,0],"ix":2},"a":{"a":0,"k":[0.05,0.05,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[35.3,35.3],[0,36.3],[-12.2,11.8],[-8,0],[-4.4,-6.2],[0,0],[7.5,-9.9],[0,0],[0,-1.7],[-0.7,-1.4],[-11.9,-12.3],[0,0],[-7.5,-4],[-1.8,0],[-1.4,1.4],[0,0],[-10.6,-6.6],[0,0],[0,-6.1],[6.5,-6.8],[16.6,0]],"o":[[-35.4,-35.4],[0,-16.1],[7.4,-7.2],[6.9,0],[0,0],[6.5,10.5],[0,0],[-1.4,1.4],[0,1.5],[3.5,7.3],[0,0],[12.8,12.9],[1.4,0.6],[1.8,0],[0,0],[9.9,-7.6],[0,0],[6.2,4.3],[0,8.5],[-11.7,12.6],[-36.4,-0.1]],"v":[[-53.3,53],[-116.1,-62.4],[-98.6,-104.9],[-74.6,-116.1],[-57.1,-107.1],[-35.6,-72.3],[-37.2,-38.4],[-52.4,-18.3],[-54.3,-13.7],[-53,-9.1],[-27.6,22.8],[-25.3,25.1],[8.7,52.8],[13.5,54.1],[18.3,52.2],[38,37],[72.1,35.3],[107.7,57.4],[116.2,73.7],[104.9,97.9],[62.4,116.2]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"1","dr":0},{"tm":41,"cm":"2","dr":0}]} diff --git a/Rosetta/Resources/Lottie/TabChats.json b/Rosetta/Resources/Lottie/TabChats.json new file mode 100644 index 0000000..98ddded --- /dev/null +++ b/Rosetta/Resources/Lottie/TabChats.json @@ -0,0 +1 @@ +{"v":"4.8.0","meta":{"g":"LottieFiles AE ","a":"","k":"","d":"","tc":""},"fr":60,"ip":0,"op":37,"w":512,"h":512,"nm":"Chats 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"Null 2","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.4,"y":1},"o":{"x":0.3,"y":0},"t":0,"s":[256,256,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.7,"y":1},"o":{"x":0.6,"y":0},"t":8,"s":[256,294,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.4,"y":1},"o":{"x":0.3,"y":0},"t":19,"s":[256,232,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.7,"y":1},"o":{"x":0.3,"y":0},"t":27,"s":[256,262,0],"to":[0,0,0],"ti":[0,0,0]},{"t":34,"s":[256,256,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.4,0.4,0.4],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":1,"s":[100,100,100]},{"i":{"x":[0.7,0.7,0.7],"y":[1,1,1]},"o":{"x":[0.6,0.6,0.6],"y":[0,0,0]},"t":9,"s":[92,80,100]},{"i":{"x":[0.4,0.4,0.4],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":20,"s":[105,112,100]},{"i":{"x":[0.7,0.7,0.7],"y":[1,1,1]},"o":{"x":[0.3,0.3,0.3],"y":[0,0,0]},"t":28,"s":[100,95,100]},{"t":36,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Bubble L","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":8,"s":[-14]},{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":19,"s":[8]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":27,"s":[-2]},{"t":34,"s":[0]}],"ix":10},"p":{"a":0,"k":[-113.45,90.543,0],"ix":2},"a":{"a":0,"k":[-113.45,90.543,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-60.8,0],[0,-55.2],[60.8,0],[8.6,1.9],[16.8,-9.4],[2.7,1.1],[-7.2,10.9],[3.2,2.3],[0,31.5]],"o":[[60.8,0],[0,55.2],[-9.3,0],[-3.6,-0.8],[-11.7,6.6],[-4.5,-1.9],[7.2,-10.9],[-25,-18.3],[-0.1,-55.3]],"v":[[-34.5,-125.7],[75.5,-25.7],[-34.5,74.3],[-61.5,71.3],[-86.6,89.2],[-117.3,94.1],[-105.7,76.3],[-104.7,50],[-144.4,-25.6]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Bubble R Piece","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":2,"s":[0]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.6],"y":[0]},"t":10,"s":[12]},{"i":{"x":[0.4],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":21,"s":[-8]},{"i":{"x":[0.7],"y":[1]},"o":{"x":[0.3],"y":[0]},"t":29,"s":[2]},{"t":36,"s":[0]}],"ix":10},"p":{"a":0,"k":[112.4,121.165,0],"ix":2},"a":{"a":0,"k":[112.4,121.165,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.7,"y":1},"o":{"x":0.432,"y":0},"t":0,"s":[{"i":[[1.569,2.155],[0,-44],[25,-18.3],[-7.2,-10.9],[4.4,-1.9],[11.7,6.6],[3.6,-0.9],[9.3,0],[16.5,9],[-1.027,0.126],[0,59.411],[15.314,21.592]],"o":[[43.4,13.4],[0,31.5],[-3.2,2.3],[7.1,10.9],[-2.8,1.2],[-16.8,-9.4],[-8.6,2],[-20.6,0],[1.035,-0.103],[61.721,-7.57],[-0.088,-21.101],[-1.541,-2.172]],"v":[[70.9,-90.4],[145.5,4.3],[105.8,79.9],[106.8,106.2],[118.4,124],[87.7,119.1],[62.6,101.2],[35.6,104.2],[-20.7,90.1],[-17.607,89.756],[92.2,-25.8],[71.186,-89.842]],"c":true}]},{"i":{"x":0.4,"y":1},"o":{"x":0.61,"y":0},"t":8,"s":[{"i":[[-35.504,-11.483],[0,-44],[25,-18.3],[-7.2,-10.9],[4.4,-1.9],[11.7,6.6],[3.6,-0.9],[9.3,0],[16.5,9],[-0.689,0.37],[5.894,33.366],[12.273,11.199]],"o":[[43.4,13.4],[0,31.5],[-3.2,2.3],[7.1,10.9],[-2.8,1.2],[-16.8,-9.4],[-8.6,2],[-20.6,0],[0.755,-0.228],[40.821,-21.94],[-6.253,-33.631],[7.006,-2.798]],"v":[[70.9,-90.4],[145.5,4.3],[105.8,79.9],[106.8,106.2],[118.4,124],[87.7,119.1],[62.6,101.2],[35.6,104.2],[-24.815,87.368],[-22.573,86.644],[32.722,-21.117],[-1.515,-89.705]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.6,"y":0},"t":10,"s":[{"i":[[-37.115,-12.076],[0,-44],[25,-18.3],[-7.2,-10.9],[4.4,-1.9],[11.7,6.6],[3.6,-0.9],[9.3,0],[16.5,9],[-0.728,0.26],[6.151,32.234],[12.343,10.945]],"o":[[43.4,13.4],[0,31.5],[-3.2,2.3],[7.1,10.9],[-2.8,1.2],[-16.8,-9.4],[-8.6,2],[-20.6,0],[0.742,-0.234],[43.722,-15.614],[-6.521,-34.176],[7.372,-2.822]],"v":[[70.9,-90.4],[145.5,4.3],[105.8,79.9],[106.8,106.2],[118.4,124],[87.7,119.1],[62.6,101.2],[35.6,104.2],[-20.804,89.611],[-18.599,88.87],[35.968,-21.004],[-2.605,-90.675]],"c":true}]},{"i":{"x":0.71,"y":1},"o":{"x":0.167,"y":0.167},"t":15,"s":[{"i":[[-19.247,-9.551],[-0.275,-42.138],[25,-18.3],[-7.2,-10.9],[4.4,-1.9],[11.7,6.6],[3.6,-0.9],[9.3,0],[13.209,5.038],[1.615,2.361],[-0.353,55.136],[11.83,14.082]],"o":[[31.739,16.231],[0.225,31.498],[-3.2,2.3],[7.1,10.9],[-2.8,1.2],[-16.8,-9.4],[-8.6,2],[-20.6,0],[-0.965,-0.989],[41.328,-7.153],[-0.441,-31.073],[6.153,-0.435]],"v":[[86.326,-83.268],[145.5,4.3],[105.8,79.9],[106.8,106.2],[118.4,124],[87.7,119.1],[62.6,101.2],[35.6,104.2],[-9.996,95.52],[-10.596,93.787],[70.779,-6.574],[40.716,-94.962]],"c":true}]},{"i":{"x":0.7,"y":1},"o":{"x":0.167,"y":0.167},"t":19,"s":[{"i":[[-3.52,0.283],[-0.696,-39.297],[25,-18.3],[-7.2,-10.9],[4.4,-1.9],[11.7,6.6],[3.6,-0.9],[9.3,0],[16.5,9],[5.191,5.567],[-16.412,43.028],[11.047,18.87]],"o":[[15.573,8.892],[0.568,31.494],[-3.2,2.3],[7.1,10.9],[-2.8,1.2],[-16.8,-9.4],[-8.6,2],[-20.6,0],[-3.571,-2.141],[76.865,17.922],[8.839,-26.337],[-0.13,-2.406]],"v":[[107.119,-68.866],[145.5,4.3],[105.8,79.9],[106.8,106.2],[118.4,124],[87.7,119.1],[62.6,101.2],[35.6,104.2],[-20.714,90.036],[-25.594,86.786],[115.287,17.773],[106.417,-68.37]],"c":true}]},{"i":{"x":0.19,"y":1},"o":{"x":0.3,"y":0},"t":21,"s":[{"i":[[1.569,2.155],[-0.801,-38.584],[25,-18.3],[-7.2,-10.9],[4.4,-1.9],[11.7,6.6],[3.6,-0.9],[9.3,0],[16.5,9],[6.087,6.371],[-19.829,44.663],[10.851,20.071]],"o":[[11.358,8.209],[0.654,31.493],[-3.2,2.3],[7.1,10.9],[-2.8,1.2],[-16.8,-9.4],[-8.6,2],[-20.6,0],[-4.225,-2.43],[61.906,22.357],[11.166,-25.149],[-1.267,-2.343]],"v":[[112.605,-65.604],[145.5,4.3],[105.8,79.9],[106.8,106.2],[118.4,124],[87.7,119.1],[62.6,101.2],[35.6,104.2],[-20.7,90.1],[-46.104,71.92],[109.444,16.764],[112.684,-63.641]],"c":true}]},{"i":{"x":0.7,"y":1},"o":{"x":0.3,"y":0},"t":29,"s":[{"i":[[1.569,2.155],[0,-44],[25,-18.3],[-7.2,-10.9],[4.4,-1.9],[11.7,6.6],[3.6,-0.9],[9.3,0],[16.5,9],[-1.027,0.126],[0,59.411],[10.648,17.024]],"o":[[51.204,13.806],[0,31.5],[-3.2,2.3],[7.1,10.9],[-2.8,1.2],[-16.8,-9.4],[-8.6,2],[-20.6,0],[1.035,-0.103],[61.721,-7.57],[-0.088,-21.101],[-1.412,-2.258]],"v":[[62.059,-92.843],[145.5,4.3],[105.8,79.9],[106.8,106.2],[118.4,124],[87.7,119.1],[62.6,101.2],[35.6,104.2],[-20.7,90.1],[-17.607,89.756],[86.16,-26.84],[66.533,-86.222]],"c":true}]},{"t":36,"s":[{"i":[[1.569,2.155],[0,-44],[25,-18.3],[-7.2,-10.9],[4.4,-1.9],[11.7,6.6],[3.6,-0.9],[9.3,0],[16.5,9],[-1.027,0.126],[0,59.411],[10.648,17.024]],"o":[[43.4,13.4],[0,31.5],[-3.2,2.3],[7.1,10.9],[-2.8,1.2],[-16.8,-9.4],[-8.6,2],[-20.6,0],[1.035,-0.103],[61.721,-7.57],[-0.088,-21.101],[-1.412,-2.258]],"v":[[70.9,-90.4],[145.5,4.3],[105.8,79.9],[106.8,106.2],[118.4,124],[87.7,119.1],[62.6,101.2],[35.6,104.2],[-20.7,90.1],[-17.607,89.756],[92.2,-25.8],[75.374,-83.779]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"1","dr":0},{"tm":88,"cm":"2","dr":0}]} \ No newline at end of file diff --git a/Rosetta/Resources/Lottie/TabSettings.json b/Rosetta/Resources/Lottie/TabSettings.json new file mode 100644 index 0000000..0064b19 --- /dev/null +++ b/Rosetta/Resources/Lottie/TabSettings.json @@ -0,0 +1 @@ +{"v":"4.8.0","meta":{"g":"LottieFiles AE ","a":"","k":"","d":"","tc":""},"fr":60,"ip":0,"op":44,"w":512,"h":512,"nm":"Settings 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"NULL ALL","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[256,256,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.3,0.3,0.3],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.32,0.32,0.32],"y":[3.958,3.958,-4.339]},"o":{"x":[0.199,0.199,0.027],"y":[0,0,0]},"t":12,"s":[110,110,100]},{"i":{"x":[0.292,0.292,0.609],"y":[1,1,1]},"o":{"x":[0.278,0.278,0.278],"y":[0.099,0.099,1.795]},"t":29,"s":[110,110,100]},{"t":43,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Circle","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0.667,-0.667,0],"ix":2},"a":{"a":0,"k":[0.667,-0.667,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.3,"y":1},"o":{"x":0.199,"y":0},"t":2,"s":[{"i":[[0,-55.136],[55.136,0],[0,55.136],[-55.136,0]],"o":[[0,55.136],[-55.136,0],[0,-55.136],[55.136,0]],"v":[[100.5,-0.667],[0.667,99.167],[-99.167,-0.667],[0.667,-100.5]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.199,"y":0},"t":12,"s":[{"i":[[0,-32.651],[32.651,0],[0,32.651],[-32.651,0]],"o":[[0,32.651],[-32.651,0],[0,-32.651],[32.651,0]],"v":[[58.476,-1.024],[-0.643,58.095],[-59.762,-1.024],[-0.643,-60.143]],"c":true}]},{"i":{"x":0.685,"y":1},"o":{"x":0.452,"y":0.444},"t":16,"s":[{"i":[[0,-32.15],[32.15,0],[0,32.15],[-32.15,0]],"o":[[0,32.15],[-32.15,0],[0,-32.15],[32.15,0]],"v":[[56.887,-0.472],[-1.326,57.742],[-59.54,-0.472],[-1.326,-58.685]],"c":true}]},{"i":{"x":0.3,"y":1},"o":{"x":1,"y":0},"t":28,"s":[{"i":[[0,-58.226],[58.226,0],[0,58.226],[-58.226,0]],"o":[[0,58.226],[-58.226,0],[0,-58.226],[58.226,0]],"v":[[105.646,-0.623],[0.219,104.804],[-105.208,-0.623],[0.219,-106.05]],"c":true}]},{"t":43,"s":[{"i":[[0,-55.136],[55.136,0],[0,55.136],[-55.136,0]],"o":[[0,55.136],[-55.136,0],[0,-55.136],[55.136,0]],"v":[[100.5,-0.667],[0.667,99.167],[-99.167,-0.667],[0.667,-100.5]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.3],"y":[1]},"o":{"x":[0.199],"y":[0]},"t":2,"s":[23]},{"i":{"x":[0.3],"y":[1]},"o":{"x":[0.199],"y":[0]},"t":12,"s":[11]},{"i":{"x":[0.662],"y":[1.231]},"o":{"x":[0.331],"y":[0]},"t":16,"s":[7]},{"i":{"x":[0.469],"y":[0.527]},"o":{"x":[0.218],"y":[0.166]},"t":18,"s":[5]},{"i":{"x":[0.71],"y":[1]},"o":{"x":[0.237],"y":[0.316]},"t":28,"s":[14]},{"t":43,"s":[23]}],"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Gears","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.431],"y":[1]},"o":{"x":[0.284],"y":[0]},"t":0,"s":[0]},{"t":43,"s":[-120]}],"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.3,0.3,0.3],"y":[1,1,1]},"o":{"x":[0.199,0.199,0.199],"y":[0,0,0]},"t":2,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.003,1.003,1.006]},"o":{"x":[0.199,0.199,0.199],"y":[0,0,0]},"t":12,"s":[50,50,100]},{"i":{"x":[0.685,0.685,0.685],"y":[1,1,1]},"o":{"x":[0.452,0.452,0.452],"y":[-0.008,-0.008,-0.104]},"t":14,"s":[52,52,100]},{"i":{"x":[0.3,0.3,0.3],"y":[1,1,1]},"o":{"x":[1,1,1],"y":[0,0,0]},"t":28,"s":[101,101,100]},{"t":43,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[6.674,0],[6.348,1.134],[0,0],[0,0],[0,-3.927],[0,0],[0,0],[-5.128,0],[0,5.523],[0,0],[0,0],[-3.744,1.182],[0,0]],"o":[[-6.674,0],[0,0],[0,0],[3.515,1.584],[0,0],[0,0],[0.578,4.973],[5.523,0],[0,0],[0,0],[0.452,-3.829],[0,0],[-6.348,1.134]],"v":[[0,110.5],[-19.559,108.774],[-16.919,109.482],[-15.829,109.897],[-10,118.922],[-10,126.667],[-9.933,127.833],[0,136.667],[10,126.667],[10,118.922],[10.068,117.758],[16.919,109.482],[19.559,108.774]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[9.97,8.477],[0,0],[2.16,-3.741],[0,0],[-4.783,-2.761],[-2.761,4.783],[0,0],[-4.217,-0.933],[0,0]],"o":[[0,0],[2.917,3.186],[0,0],[-2.761,4.783],[4.783,2.761],[0,0],[2.16,-3.741],[0,0],[-12.55,-4.53]],"v":[[-71.533,84.215],[-69.393,86.355],[-68.121,97.99],[-71.994,104.697],[-68.333,118.357],[-54.673,114.697],[-50.801,107.99],[-40.089,103.273],[-37.52,103.962]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[4.53,12.55],[0,0],[3.741,-2.16],[0,0],[-2.761,-4.783],[-4.783,2.761],[0,0],[-3.186,-2.917],[0,0]],"o":[[0,0],[0.933,4.217],[0,0],[-4.783,2.761],[2.761,4.783],[0,0],[3.741,-2.16],[0,0],[-8.477,-9.97]],"v":[[-103.962,37.52],[-103.273,40.089],[-107.99,50.801],[-114.697,54.673],[-118.357,68.333],[-104.697,71.994],[-97.99,68.121],[-86.355,69.393],[-84.215,71.533]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-2.917,3.186],[0,0],[12.55,-4.53],[0,0],[-2.16,-3.741],[0,0],[-4.783,2.761],[2.761,4.783]],"o":[[-2.16,-3.741],[0,0],[-9.97,8.477],[0,0],[4.217,-0.933],[0,0],[2.761,4.783],[4.783,-2.761],[0,0]],"v":[[68.121,97.99],[69.393,86.355],[71.533,84.215],[37.52,103.962],[40.089,103.273],[50.801,107.99],[54.673,114.697],[68.333,118.357],[71.994,104.697]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[5.523,0],[0,0],[1.301,4.119],[0,0],[0,-6.674],[1.134,-6.348],[0,0],[-4.319,0],[0,0],[0,5.523]],"o":[[0,0],[-4.319,0],[0,0],[1.134,6.348],[0,6.674],[0,0],[1.301,-4.119],[0,0],[5.523,0],[0,-5.523]],"v":[[126.667,-10],[118.922,-10],[109.482,-16.919],[108.774,-19.559],[110.5,0],[108.774,19.559],[109.482,16.919],[118.922,10],[126.667,10],[136.667,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":2,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[4.783,2.761],[0,0],[-0.933,4.217],[0,0],[8.477,-9.97],[0,0],[-3.74,-2.16],[0,0],[-2.761,4.783]],"o":[[0,0],[-3.74,-2.16],[0,0],[-4.53,12.55],[0,0],[3.186,-2.917],[0,0],[4.783,2.761],[2.761,-4.783]],"v":[[114.697,54.673],[107.99,50.801],[103.273,40.089],[103.962,37.52],[84.215,71.533],[86.355,69.393],[97.99,68.121],[104.697,71.994],[118.357,68.333]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":2,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-6.674,0],[-6.348,-1.134],[0,0],[0,4.319],[0,0],[5.523,0],[0,-5.523],[0,0],[4.119,-1.301],[0,0]],"o":[[6.674,0],[0,0],[-4.119,-1.301],[0,0],[0,-5.523],[-5.523,0],[0,0],[0,4.319],[0,0],[6.348,-1.134]],"v":[[0,-110.5],[19.559,-108.774],[16.919,-109.482],[10,-118.922],[10,-126.667],[0,-136.667],[-10,-126.667],[-10,-118.922],[-16.919,-109.482],[-19.559,-108.774]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 7","np":2,"cix":2,"bm":0,"ix":7,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-9.97,-8.477],[0,0],[-2.16,3.741],[0,0],[4.783,2.761],[2.761,-4.783],[0,0],[4.217,0.933],[0,0]],"o":[[0,0],[-2.917,-3.186],[0,0],[2.761,-4.783],[-4.783,-2.761],[0,0],[-2.16,3.741],[0,0],[12.55,4.53]],"v":[[71.533,-84.215],[69.393,-86.355],[68.121,-97.99],[71.994,-104.697],[68.333,-118.357],[54.673,-114.697],[50.801,-107.99],[40.089,-103.273],[37.52,-103.962]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 8","np":2,"cix":2,"bm":0,"ix":8,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-4.53,-12.55],[0,0],[-3.74,2.16],[0,0],[2.761,4.783],[4.783,-2.761],[0,0],[3.186,2.917],[0,0]],"o":[[0,0],[-0.933,-4.217],[0,0],[4.783,-2.761],[-2.761,-4.783],[0,0],[-3.74,2.16],[0,0],[8.477,9.97]],"v":[[103.962,-37.52],[103.273,-40.089],[107.99,-50.801],[114.697,-54.673],[118.357,-68.333],[104.697,-71.994],[97.99,-68.121],[86.355,-69.393],[84.215,-71.533]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 9","np":2,"cix":2,"bm":0,"ix":9,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-4.783,-2.761],[0,0],[0.933,-4.217],[0,0],[-8.477,9.97],[0,0],[3.741,2.16],[0,0],[2.761,-4.783]],"o":[[0,0],[3.741,2.16],[0,0],[4.53,-12.55],[0,0],[-3.186,2.917],[0,0],[-4.783,-2.761],[-2.761,4.783]],"v":[[-114.697,-54.673],[-107.99,-50.801],[-103.273,-40.089],[-103.962,-37.52],[-84.215,-71.533],[-86.355,-69.393],[-97.99,-68.121],[-104.697,-71.994],[-118.357,-68.333]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 10","np":2,"cix":2,"bm":0,"ix":10,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.301,-4.119],[0,0],[0,6.674],[-1.134,6.348],[0,0],[4.319,0],[0,0],[0,-5.523],[-5.523,0],[0,0]],"o":[[0,0],[-1.134,-6.348],[0,-6.674],[0,0],[-1.301,4.119],[0,0],[-5.523,0],[0,5.523],[0,0],[4.319,0]],"v":[[-109.482,16.919],[-108.774,19.559],[-110.5,0],[-108.774,-19.559],[-109.482,-16.919],[-118.922,-10],[-126.667,-10],[-136.667,0],[-126.667,10],[-118.922,10]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 11","np":2,"cix":2,"bm":0,"ix":11,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[2.917,-3.186],[0,0],[-12.55,4.53],[0,0],[2.16,3.741],[0,0],[4.783,-2.761],[-2.761,-4.783]],"o":[[2.16,3.741],[0,0],[9.97,-8.477],[0,0],[-4.217,0.933],[0,0],[-2.761,-4.783],[-4.783,2.761],[0,0]],"v":[[-68.121,-97.99],[-69.393,-86.355],[-71.533,-84.215],[-37.52,-103.962],[-40.089,-103.273],[-50.801,-107.99],[-54.673,-114.697],[-68.333,-118.357],[-71.994,-104.697]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 12","np":2,"cix":2,"bm":0,"ix":12,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Center","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.615],"y":[1]},"o":{"x":[0.2],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1.002]},"o":{"x":[0.335],"y":[0]},"t":10,"s":[-50]},{"i":{"x":[0.3],"y":[1]},"o":{"x":[0.057],"y":[0]},"t":11,"s":[-175]},{"t":38,"s":[0]}],"ix":10},"p":{"a":0,"k":[-1.289,0,0],"ix":2},"a":{"a":0,"k":[-1.289,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.3,0.3,0.3],"y":[1,1,1]},"o":{"x":[0.2,0.2,0.2],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.3,0.3,0.3],"y":[1,1,1]},"o":{"x":[0.15,0.15,0.15],"y":[0,0,0]},"t":12,"s":[70,70,100]},{"t":38,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-6.986,0],[-9.71,-29.167],[0,0],[5.141,-1.512],[0,0],[1.868,2.699],[0,0],[-3.849,2.664],[0,0]],"o":[[32.43,0],[0,0],[1.512,5.141],[-0.889,0.262],[-3.282,0],[0,0],[-2.663,-3.849],[0.509,-0.352],[6.401,-1.823]],"v":[[0,-73.4],[69.658,-23.201],[70.425,-20.752],[63.855,-8.705],[14.587,-8.305],[6.363,-12.614],[-24.792,-57.628],[-22.646,-69.419],[-20.145,-70.601]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.3,"y":1},"o":{"x":0.199,"y":0},"t":0,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.32,"y":0.617},"o":{"x":0.199,"y":0},"t":12,"s":[-3.475,5.769],"to":[0,0],"ti":[0,0]},{"i":{"x":0.292,"y":1},"o":{"x":0.278,"y":0.099},"t":29,"s":[5.001,-11.271],"to":[0,0],"ti":[0,0]},{"t":43,"s":[0,0]}],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-4.066,-3.716],[0,0],[2.371,-3.425],[0,0],[4.39,3.039],[0,0],[0,19.231],[-10.767,12.772],[0,0]],"o":[[0.552,0.505],[2.371,3.425],[0,0],[-3.039,4.39],[0,0],[-12.103,-13.087],[0,-18.013],[0,0],[3.716,-4.066]],"v":[[-40.109,-50.132],[-9.036,-5.692],[-9.036,5.692],[-38.855,48.762],[-52.307,51.207],[-53.901,49.822],[-73.4,0],[-56.144,-47.282],[-54.2,-49.497]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.3,"y":1},"o":{"x":0.199,"y":0},"t":0,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.32,"y":0.625},"o":{"x":0.199,"y":0},"t":12,"s":[5.858,-0.402],"to":[0,0],"ti":[0,0]},{"i":{"x":0.292,"y":1},"o":{"x":0.278,"y":0.099},"t":29,"s":[-10.124,0.87],"to":[0,0],"ti":[0,0]},{"t":43,"s":[0,0]}],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,-5.523],[0,0],[0,0],[33.102,0],[4.923,1.041],[0,0],[-1.317,4.95],[0,0],[-3.282,0]],"o":[[5.523,0.001],[0,0.64],[0,0],[-9.153,30.11],[-5.233,0],[0,0],[-4.95,-1.317],[0.275,-1.035],[1.868,-2.699],[0,0]],"v":[[60.722,8.3],[70.721,18.301],[70.238,21.378],[70.238,21.378],[0,73.4],[-15.262,71.811],[-18.919,70.939],[-25.498,59.591],[6.353,12.605],[14.577,8.296]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.3,"y":1},"o":{"x":0.199,"y":0},"t":0,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.32,"y":0.621},"o":{"x":0.199,"y":0},"t":12,"s":[-2.549,-4.624],"to":[0,0],"ti":[0,0]},{"i":{"x":0.292,"y":1},"o":{"x":0.278,"y":0.099},"t":29,"s":[6.634,6.311],"to":[0,0],"ti":[0,0]},{"t":43,"s":[0,0]}],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"bm":0}],"markers":[]} \ No newline at end of file