diff --git a/Rosetta/Assets.xcassets/ChatWallpaper.imageset/Contents.json b/Rosetta/Assets.xcassets/ChatWallpaper.imageset/Contents.json index 1f5febd..6736175 100644 --- a/Rosetta/Assets.xcassets/ChatWallpaper.imageset/Contents.json +++ b/Rosetta/Assets.xcassets/ChatWallpaper.imageset/Contents.json @@ -1,7 +1,24 @@ { "images" : [ { - "filename" : "back_5.png", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "wallpaper_light_02.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "wallpaper_dark_03.png", "idiom" : "universal", "scale" : "1x" } diff --git a/Rosetta/Assets.xcassets/ChatWallpaper.imageset/wallpaper_dark_03.png b/Rosetta/Assets.xcassets/ChatWallpaper.imageset/wallpaper_dark_03.png new file mode 100644 index 0000000..d6144df Binary files /dev/null and b/Rosetta/Assets.xcassets/ChatWallpaper.imageset/wallpaper_dark_03.png differ diff --git a/Rosetta/Assets.xcassets/ChatWallpaper.imageset/wallpaper_light_02.png b/Rosetta/Assets.xcassets/ChatWallpaper.imageset/wallpaper_light_02.png new file mode 100644 index 0000000..dd3c8be Binary files /dev/null and b/Rosetta/Assets.xcassets/ChatWallpaper.imageset/wallpaper_light_02.png differ diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 0604154..c2e8da7 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -99,6 +99,7 @@ extension MessageCellLayout { struct Config: Sendable { let maxBubbleWidth: CGFloat let isOutgoing: Bool + let isDarkMode: Bool let position: BubblePosition let deliveryStatus: DeliveryStatus let text: String @@ -222,7 +223,8 @@ extension MessageCellLayout { if !config.text.isEmpty && needsDetailedTextLayout { // CoreText (CTTypesetter) β€” returns per-line widths including lastLineWidth. // Also captures CoreTextTextLayout for cell rendering (avoids double computation). - let (measurement, layout) = measureTextDetailedWithLayout(config.text, maxWidth: max(maxTextWidth, 50), font: font) + let bubbleTextColor: UIColor = (config.isOutgoing || config.isDarkMode) ? .white : .black + let (measurement, layout) = measureTextDetailedWithLayout(config.text, maxWidth: max(maxTextWidth, 50), font: font, textColor: bubbleTextColor) textMeasurement = measurement cachedTextLayout = layout } else if !config.text.isEmpty { @@ -690,10 +692,10 @@ extension MessageCellLayout { /// Returns BOTH measurement AND the full CoreTextTextLayout for cell rendering cache. /// This eliminates the double CoreText computation (measure + render). private static func measureTextDetailedWithLayout( - _ text: String, maxWidth: CGFloat, font: UIFont + _ text: String, maxWidth: CGFloat, font: UIFont, textColor: UIColor = .white ) -> (TextMeasurement, CoreTextTextLayout) { let layout = CoreTextTextLayout.calculate( - text: text, maxWidth: maxWidth, font: font, textColor: .white + text: text, maxWidth: maxWidth, font: font, textColor: textColor ) let measurement = TextMeasurement( size: layout.size, @@ -835,7 +837,8 @@ extension MessageCellLayout { currentPublicKey: String, opponentPublicKey: String, opponentTitle: String, - isGroupChat: Bool = false + isGroupChat: Bool = false, + isDarkMode: Bool = true ) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) { var result: [String: MessageCellLayout] = [:] var textResult: [String: CoreTextTextLayout] = [:] @@ -944,6 +947,7 @@ extension MessageCellLayout { let config = Config( maxBubbleWidth: maxBubbleWidth, isOutgoing: isOutgoing, + isDarkMode: isDarkMode, position: position, deliveryStatus: message.deliveryStatus, text: isForward ? (forwardCaption ?? "") : displayText, diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 1effb9b..917f8dc 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -859,6 +859,96 @@ final class SessionManager { // Forward messages have empty text β€” only the MESSAGES attachment carries content. let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines) + + // Build the reply JSON blob (shared by both group and direct paths) + let replyJSON = try JSONEncoder().encode(replyMessages) + guard let replyJSONString = String(data: replyJSON, encoding: .utf8) else { + throw CryptoError.encryptionFailed + } + + let replyAttachmentId = "reply_\(timestamp)" + + // --- Group path: encrypt with groupKey, no ECDH --- + let isGroup = DatabaseManager.isGroupDialogKey(toPublicKey) + if isGroup { + let normalizedTarget = Self.normalizedGroupDialogIdentity(toPublicKey) + guard let groupKey = GroupRepository.shared.groupKey( + account: currentPublicKey, + privateKeyHex: privKey, + groupDialogKey: normalizedTarget + ) else { + throw CryptoError.invalidData("Missing group key for \(normalizedTarget)") + } + + // Desktop parity: text encrypted with plain groupKey + let encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + Data(messageText.utf8), + password: groupKey + ) + + // Desktop parity: reply blob encrypted with Buffer.from(groupKey).toString('hex') + let hexGroupKey = Data(groupKey.utf8).map { String(format: "%02x", $0) }.joined() + let encryptedReplyBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + replyJSON, + password: hexGroupKey + ) + + let replyAttachment = MessageAttachment( + id: replyAttachmentId, preview: "", blob: encryptedReplyBlob, type: .messages + ) + + // Group packets: no ECDH keys + var packet = PacketMessage() + packet.fromPublicKey = currentPublicKey + packet.toPublicKey = normalizedTarget + packet.content = encryptedContent + packet.chachaKey = "" + packet.timestamp = timestamp + packet.privateKey = hash + packet.messageId = messageId + packet.aesChachaKey = "" + packet.attachments = [replyAttachment] + + // Local copy with decrypted blob for UI + let localReplyAttachment = MessageAttachment( + id: replyAttachmentId, preview: "", blob: replyJSONString, type: .messages + ) + var localPacket = packet + localPacket.attachments = [localReplyAttachment] + + let existingDialog = DialogRepository.shared.dialogs[normalizedTarget] + let groupMeta = GroupRepository.shared.groupMetadata( + account: currentPublicKey, groupDialogKey: normalizedTarget + ) + let title = !opponentTitle.isEmpty ? opponentTitle + : (existingDialog?.opponentTitle.isEmpty == false ? existingDialog!.opponentTitle + : (groupMeta?.title ?? "")) + let username = !opponentUsername.isEmpty ? opponentUsername + : (existingDialog?.opponentUsername ?? "") + DialogRepository.shared.ensureDialog( + opponentKey: normalizedTarget, title: title, username: username, + myPublicKey: currentPublicKey + ) + + MessageRepository.shared.upsertFromMessagePacket( + localPacket, myPublicKey: currentPublicKey, + decryptedText: messageText, attachmentPassword: hexGroupKey, + fromSync: false, dialogIdentityOverride: normalizedTarget + ) + DialogRepository.shared.updateDialogFromMessages(opponentKey: normalizedTarget) + + packetFlowSender.sendPacket(packet) + // Server does NOT send delivery ACK for group messages + MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) + DialogRepository.shared.updateDeliveryStatus( + messageId: messageId, opponentKey: normalizedTarget, status: .delivered + ) + MessageRepository.shared.persistNow() + Self.logger.info("πŸ“€ Group reply sent to \(normalizedTarget.prefix(12))… with \(replyMessages.count) quoted message(s)") + return + } + + // --- Direct message path: ECDH encryption --- let encrypted = try MessageCrypto.encryptOutgoing( plaintext: messageText, recipientPublicKeyHex: toPublicKey @@ -868,40 +958,17 @@ final class SessionManager { // Desktop commit 61e83bd: changed from Buffer.toString('utf-8') to key.toString('hex'). let replyPassword = encrypted.plainKeyAndNonce.hexString - #if DEBUG - Self.logger.debug("πŸ“€ Reply password (hex): \(replyPassword)") - #endif - // Desktop commit aaa4b42: no re-upload needed for forwards. // chacha_key_plain in ReplyMessageData carries the original message's key, // so the recipient can decrypt original CDN blobs directly. - // Build the reply JSON blob - let replyJSON = try JSONEncoder().encode(replyMessages) - guard let replyJSONString = String(data: replyJSON, encoding: .utf8) else { - throw CryptoError.encryptionFailed - } - // Encrypt reply blob with desktop-compatible encryption let encryptedReplyBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( replyJSON, password: replyPassword ) - #if DEBUG - Self.logger.debug("πŸ“€ Reply blob: \(replyJSON.count) raw β†’ \(encryptedReplyBlob.count) encrypted bytes") - // Self-test: decrypt with the same WHATWG password - if let selfTestData = try? CryptoManager.shared.decryptWithPassword( - encryptedReplyBlob, password: replyPassword, requireCompression: true - ), String(data: selfTestData, encoding: .utf8) != nil { - Self.logger.debug("πŸ“€ Reply blob self-test PASSED") - } else { - Self.logger.error("πŸ“€ Reply blob self-test FAILED") - } - #endif - // Build reply attachment - let replyAttachmentId = "reply_\(timestamp)" let replyAttachment = MessageAttachment( id: replyAttachmentId, preview: "", @@ -955,11 +1022,10 @@ final class SessionManager { // Optimistic UI update β€” use localPacket (decrypted blob) for storage. // Android parity: always insert as WAITING (fromSync: false). - let displayText = messageText MessageRepository.shared.upsertFromMessagePacket( localPacket, myPublicKey: currentPublicKey, - decryptedText: displayText, + decryptedText: messageText, attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: false ) @@ -975,14 +1041,7 @@ final class SessionManager { } packetFlowSender.sendPacket(packet) - if DatabaseManager.isGroupDialogKey(toPublicKey) { - MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) - DialogRepository.shared.updateDeliveryStatus( - messageId: messageId, opponentKey: toPublicKey, status: .delivered - ) - } else { - registerOutgoingRetry(for: packet) - } + registerOutgoingRetry(for: packet) MessageRepository.shared.persistNow() Self.logger.info("πŸ“€ Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s)") } @@ -1987,13 +2046,19 @@ final class SessionManager { } } else if let groupKey { // Desktop parity: Buffer.from(groupKey).toString('hex') - resolvedAttachmentPassword = Data(groupKey.utf8).map { String(format: "%02x", $0) }.joined() + let hexGroupKey = Data(groupKey.utf8).map { String(format: "%02x", $0) }.joined() + resolvedAttachmentPassword = hexGroupKey for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages { let blob = processedPacket.attachments[i].blob guard !blob.isEmpty else { continue } - if let data = try? CryptoManager.shared.decryptWithPassword(blob, password: groupKey), + // Desktop encrypts reply/forward blobs with hex-encoded groupKey (primary), + // fallback to plain groupKey for backward compat. + if let data = try? CryptoManager.shared.decryptWithPassword(blob, password: hexGroupKey), let decryptedString = String(data: data, encoding: .utf8) { processedPacket.attachments[i].blob = decryptedString + } else if let data = try? CryptoManager.shared.decryptWithPassword(blob, password: groupKey), + let decryptedString = String(data: data, encoding: .utf8) { + processedPacket.attachments[i].blob = decryptedString } } } diff --git a/Rosetta/Core/Utils/DarkMode+Helpers.swift b/Rosetta/Core/Utils/DarkMode+Helpers.swift new file mode 100644 index 0000000..c1e20e5 --- /dev/null +++ b/Rosetta/Core/Utils/DarkMode+Helpers.swift @@ -0,0 +1,207 @@ +import SwiftUI + +// MARK: - Dark Mode Animation System +// Ported from DarkModeAnimation reference project (Balaji Venkatesh). +// Circular reveal mask animation for theme switching. + +// Step 1: Wrap app's main content with DarkModeWrapper in RosettaApp. +// Step 2: Place DarkModeButton wherever the toggle should appear. + +/// Creates an overlay UIWindow for the dark mode transition animation. +struct DarkModeWrapper: View { + @ViewBuilder var content: Content + @State private var overlayWindow: UIWindow? + @AppStorage("rosetta_dark_mode") private var activateDarkMode: Bool = true + + var body: some View { + content + .onAppear { + if overlayWindow == nil { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { + let overlayWindow = UIWindow(windowScene: windowScene) + overlayWindow.tag = 0320 + overlayWindow.isHidden = false + overlayWindow.isUserInteractionEnabled = false + self.overlayWindow = overlayWindow + } + } + } + .onChange(of: activateDarkMode, initial: true) { _, newValue in + if let keyWindow = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) { + keyWindow.overrideUserInterfaceStyle = newValue ? .dark : .light + } + } + } +} + +/// Theme toggle button with sun/moon icon and circular reveal animation. +struct DarkModeButton: View { + @State private var buttonRect: CGRect = .zero + @AppStorage("rosetta_dark_mode") private var toggleDarkMode: Bool = true + @AppStorage("rosetta_dark_mode") private var activateDarkMode: Bool = true + + var body: some View { + Button(action: { + toggleDarkMode.toggle() + animateScheme() + }, label: { + Image(systemName: toggleDarkMode ? "moon.fill" : "sun.max.fill") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .symbolEffect(.bounce, value: toggleDarkMode) + .frame(width: 44, height: 44) + }) + .buttonStyle(.plain) + .darkModeButtonRect { rect in + buttonRect = rect + } + } + + @MainActor + func animateScheme() { + Task { + if let windows = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows, + let window = windows.first(where: { $0.isKeyWindow }), + let overlayWindow = windows.first(where: { $0.tag == 0320 }) { + + overlayWindow.isUserInteractionEnabled = true + let imageView = UIImageView() + imageView.frame = window.frame + imageView.image = window.darkModeSnapshot(window.frame.size) + imageView.contentMode = .scaleAspectFit + overlayWindow.addSubview(imageView) + + let frameSize = window.frame.size + // Capture old state + activateDarkMode = !toggleDarkMode + let previousImage = window.darkModeSnapshot(frameSize) + // Switch to new state + activateDarkMode = toggleDarkMode + // Allow layout to settle + try await Task.sleep(for: .seconds(0.01)) + let currentImage = window.darkModeSnapshot(frameSize) + + try await Task.sleep(for: .seconds(0.01)) + + let swiftUIView = DarkModeOverlayView( + buttonRect: buttonRect, + previousImage: previousImage, + currentImage: currentImage + ) + + let hostingController = UIHostingController(rootView: swiftUIView) + hostingController.view.backgroundColor = .clear + hostingController.view.frame = window.frame + hostingController.view.tag = 1009 + overlayWindow.addSubview(hostingController.view) + imageView.removeFromSuperview() + } + } + } +} + +// MARK: - Button Rect Tracking + +private struct DarkModeRectKey: PreferenceKey { + static var defaultValue: CGRect = .zero + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + value = nextValue() + } +} + +private extension View { + @ViewBuilder + func darkModeButtonRect(value: @escaping (CGRect) -> Void) -> some View { + self + .overlay { + GeometryReader { geometry in + let rect = geometry.frame(in: .global) + Color.clear + .preference(key: DarkModeRectKey.self, value: rect) + .onPreferenceChange(DarkModeRectKey.self) { rect in + value(rect) + } + } + } + } +} + +// MARK: - Circular Mask Animation Overlay + +private struct DarkModeOverlayView: View { + var buttonRect: CGRect + @State var previousImage: UIImage? + @State var currentImage: UIImage? + @State private var maskAnimation: Bool = false + + var body: some View { + GeometryReader { geometry in + let size = geometry.size + let maskRadius = size.height / 10 + + if let previousImage, let currentImage { + ZStack { + Image(uiImage: previousImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size.width, height: size.height) + + Image(uiImage: currentImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size.width, height: size.height) + .mask(alignment: .topLeading) { + Circle() + .frame( + width: buttonRect.width * (maskAnimation ? maskRadius : 1), + height: buttonRect.height * (maskAnimation ? maskRadius : 1), + alignment: .bottomLeading + ) + .frame(width: buttonRect.width, height: buttonRect.height) + .offset(x: buttonRect.minX, y: buttonRect.minY) + .ignoresSafeArea() + } + } + .task { + guard !maskAnimation else { return } + + withAnimation(.easeInOut(duration: 0.9), completionCriteria: .logicallyComplete) { + maskAnimation = true + } completion: { + self.previousImage = nil + self.currentImage = nil + maskAnimation = false + if let window = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first(where: { $0.tag == 0320 }) { + for view in window.subviews { + view.removeFromSuperview() + } + window.isUserInteractionEnabled = false + } + } + } + } + } + // Reverse masking β€” cut out the button area so original button stays visible + .mask { + Rectangle() + .overlay(alignment: .topLeading) { + Circle() + .frame(width: buttonRect.width, height: buttonRect.height) + .offset(x: buttonRect.minX, y: buttonRect.minY) + .blendMode(.destinationOut) + } + } + .ignoresSafeArea() + } +} + +// MARK: - UIView Snapshot + +private extension UIView { + func darkModeSnapshot(_ size: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: true) + } + } +} diff --git a/Rosetta/DesignSystem/Colors.swift b/Rosetta/DesignSystem/Colors.swift index 9d643ea..69872af 100644 --- a/Rosetta/DesignSystem/Colors.swift +++ b/Rosetta/DesignSystem/Colors.swift @@ -22,8 +22,8 @@ enum RosettaColors { // MARK: Auth Backgrounds - static let authBackground = Color.black - static let authSurface = Color(hex: 0x2A2A2A) + static let authBackground = RosettaColors.adaptive(light: Color.white, dark: Color.black) + static let authSurface = RosettaColors.adaptive(light: Color(hex: 0xF2F2F7), dark: Color(hex: 0x2A2A2A)) // MARK: Shared Neutral @@ -97,6 +97,18 @@ enum RosettaColors { static let messageBubble = RosettaColors.adaptive(light: RosettaColors.Light.messageBubble, dark: RosettaColors.Dark.messageBubble) static let messageBubbleOwn = RosettaColors.adaptive(light: RosettaColors.Light.messageBubbleOwn, dark: RosettaColors.Dark.messageBubbleOwn) static let inputBackground = RosettaColors.adaptive(light: RosettaColors.Light.inputBackground, dark: RosettaColors.Dark.inputBackground) + static let pinnedSectionBackground = RosettaColors.adaptive( + light: Color(hex: 0xF2F2F7), + dark: RosettaColors.Dark.pinnedSectionBackground + ) + static let searchBarFill = RosettaColors.adaptive( + light: Color.black.opacity(0.08), + dark: Color.white.opacity(0.08) + ) + static let searchBarBorder = RosettaColors.adaptive( + light: Color.black.opacity(0.06), + dark: Color.white.opacity(0.06) + ) } // MARK: Seed Word Colors (12 unique, matching Android) diff --git a/Rosetta/DesignSystem/Components/GlassCard.swift b/Rosetta/DesignSystem/Components/GlassCard.swift index 8239c11..ba21b40 100644 --- a/Rosetta/DesignSystem/Components/GlassCard.swift +++ b/Rosetta/DesignSystem/Components/GlassCard.swift @@ -1,6 +1,11 @@ import SwiftUI -// MARK: - Settings Card (flat #1C1C1E background, no border/blur) +private let settingsCardFill = RosettaColors.adaptive( + light: Color(hex: 0xF2F2F7), + dark: Color(red: 28/255, green: 28/255, blue: 30/255) +) + +// MARK: - Settings Card (adaptive background, no border/blur) struct SettingsCard: View { let content: () -> Content @@ -13,7 +18,7 @@ struct SettingsCard: View { content() .background( RoundedRectangle(cornerRadius: 26, style: .continuous) - .fill(Color(red: 28/255, green: 28/255, blue: 30/255)) + .fill(settingsCardFill) ) } } diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift index f2963d5..c86e660 100644 --- a/Rosetta/DesignSystem/Components/RosettaTabBar.swift +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -55,11 +55,23 @@ struct TabBarSwipeState { // MARK: - Tab Bar Colors private enum TabBarColors { - static let pillBackground = Color(hex: 0x2C2C2E) - static let selectionBackground = Color(hex: 0x3A3A3C) + 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 = Color.white - static let pillBorder = Color.white.opacity(0.08) + 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) + ) } // MARK: - Preference Keys @@ -300,12 +312,7 @@ struct RosettaTabBar: View { // MARK: - Color Interpolation - /// Pre-computed RGBA to avoid creating UIColor on every drag frame. - private static let unselectedRGBA: (CGFloat, CGFloat, CGFloat, CGFloat) = { - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - UIColor(TabBarColors.unselectedTint).getRed(&r, green: &g, blue: &b, alpha: &a) - return (r, g, b, a) - }() + @Environment(\.colorScheme) private var colorScheme private static let selectedRGBA: (CGFloat, CGFloat, CGFloat, CGFloat) = { var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 @@ -315,7 +322,12 @@ struct RosettaTabBar: View { private func tintColor(blend: CGFloat) -> Color { let t = blend.clamped(to: 0...1) - let (fr, fg, fb, fa) = Self.unselectedRGBA + // 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, diff --git a/Rosetta/DesignSystem/Components/TelegramGlassView.swift b/Rosetta/DesignSystem/Components/TelegramGlassView.swift index f75a23c..951ed74 100644 --- a/Rosetta/DesignSystem/Components/TelegramGlassView.swift +++ b/Rosetta/DesignSystem/Components/TelegramGlassView.swift @@ -128,8 +128,11 @@ final class TelegramGlassUIView: UIView { private func setupNativeGlass() { let effect = UIGlassEffect(style: .regular) effect.isInteractive = false - // Telegram dark mode tint: UIColor(white: 1.0, alpha: 0.025) - effect.tintColor = UIColor(white: 1.0, alpha: 0.025) + effect.tintColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(white: 1.0, alpha: 0.025) + : UIColor(white: 0.0, alpha: 0.025) + } let glassView = UIVisualEffectView(effect: effect) glassView.clipsToBounds = true glassView.layer.cornerCurve = .continuous @@ -164,7 +167,7 @@ final class TelegramGlassUIView: UIView { self.backdropLayer = backdrop } - // 2. Foreground β€” dark semi-transparent fill + // 2. Foreground β€” adaptive semi-transparent fill (resolved in didMoveToWindow) foregroundLayer.backgroundColor = UIColor(white: 0.11, alpha: 0.85).cgColor clippingContainer.addSublayer(foregroundLayer) @@ -238,6 +241,40 @@ final class TelegramGlassUIView: UIView { layer.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() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + // Resolve colors once view is in a window and has valid traitCollection + updateLegacyColors() + } + // MARK: - Shadow (drawn as separate image β€” Telegram parity) /// Call from parent to add Telegram-exact shadow. diff --git a/Rosetta/DesignSystem/Components/VerifiedBadge.swift b/Rosetta/DesignSystem/Components/VerifiedBadge.swift index 3c6655e..08bac05 100644 --- a/Rosetta/DesignSystem/Components/VerifiedBadge.swift +++ b/Rosetta/DesignSystem/Components/VerifiedBadge.swift @@ -55,9 +55,7 @@ struct VerifiedBadge: View { if verified == 2 { return RosettaColors.success // green } - return colorScheme == .dark - ? RosettaColors.primaryBlue // #248AE6 - : Color(hex: 0xACD2F9) // soft blue (light theme) + return RosettaColors.primaryBlue // #248AE6 β€” same both themes (Telegram parity) } private var annotationText: String { diff --git a/Rosetta/Features/Auth/AuthCoordinator.swift b/Rosetta/Features/Auth/AuthCoordinator.swift index 6584a1e..ac4af6d 100644 --- a/Rosetta/Features/Auth/AuthCoordinator.swift +++ b/Rosetta/Features/Auth/AuthCoordinator.swift @@ -88,7 +88,6 @@ struct AuthCoordinator: View { .animation(.easeInOut(duration: 0.035), value: fadeOverlay) } .simultaneousGesture(swipeBackGesture(screenWidth: screenWidth)) - .preferredColorScheme(.dark) } } } diff --git a/Rosetta/Features/Auth/UnlockView.swift b/Rosetta/Features/Auth/UnlockView.swift index 2b1bc50..b86d4b0 100644 --- a/Rosetta/Features/Auth/UnlockView.swift +++ b/Rosetta/Features/Auth/UnlockView.swift @@ -83,7 +83,7 @@ struct UnlockView: View { HStack(spacing: 8) { Text(displayTitle) .font(.system(size: 24, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(RosettaColors.Adaptive.text) // Desktop parity: DiceDropdown arrow icon for multi-account if AccountManager.shared.hasMultipleAccounts { @@ -92,7 +92,7 @@ struct UnlockView: View { } label: { Image(systemName: "arrow.left.arrow.right") .font(.system(size: 16, weight: .medium)) - .foregroundStyle(Color.white.opacity(0.6)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) } .buttonStyle(.plain) } @@ -105,7 +105,7 @@ struct UnlockView: View { // Subtitle β€” matching Android Text("For unlock account enter password") .font(.system(size: 15)) - .foregroundStyle(Color.white.opacity(0.7)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) .opacity(showSubtitle ? 1 : 0) .offset(y: showSubtitle ? 0 : 8) @@ -231,7 +231,7 @@ private extension UnlockView { Text("Unlock with \(BiometricAuthManager.shared.biometricName)") .font(.system(size: 15, weight: .medium)) } - .foregroundStyle(Color.white.opacity(0.8)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) } .buttonStyle(.plain) .disabled(isUnlocking) @@ -245,7 +245,7 @@ private extension UnlockView { VStack(spacing: 4) { HStack(spacing: 0) { Text("You can also ") - .foregroundStyle(Color.white.opacity(0.7)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) Button { onCreateNewAccount?() @@ -257,13 +257,13 @@ private extension UnlockView { .buttonStyle(.plain) Text(" or") - .foregroundStyle(Color.white.opacity(0.7)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) } .font(.system(size: 15)) HStack(spacing: 0) { Text("create a ") - .foregroundStyle(Color.white.opacity(0.7)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) Button { onCreateNewAccount?() @@ -469,7 +469,7 @@ private struct UnlockPasswordField: UIViewRepresentable { tf.isSecureTextEntry = true tf.font = .systemFont(ofSize: 16) - tf.textColor = .white + tf.textColor = .label tf.tintColor = UIColor(RosettaColors.primaryBlue) tf.autocapitalizationType = .none tf.autocorrectionType = .no @@ -488,14 +488,14 @@ private struct UnlockPasswordField: UIViewRepresentable { ) tf.attributedPlaceholder = NSAttributedString( string: "Password", - attributes: [.foregroundColor: UIColor.white.withAlphaComponent(0.3)] + attributes: [.foregroundColor: UIColor.placeholderText] ) // Eye toggle button β€” entirely UIKit, light weight for clean Mantine-like look let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin) let eyeButton = UIButton(type: .system) eyeButton.setImage(UIImage(systemName: "eye", withConfiguration: config), for: .normal) - eyeButton.tintColor = UIColor.white.withAlphaComponent(0.4) + eyeButton.tintColor = .secondaryLabel eyeButton.addTarget( context.coordinator, action: #selector(Coordinator.toggleSecure), diff --git a/Rosetta/Features/Calls/CallsView.swift b/Rosetta/Features/Calls/CallsView.swift index 0522ba3..994d4cb 100644 --- a/Rosetta/Features/Calls/CallsView.swift +++ b/Rosetta/Features/Calls/CallsView.swift @@ -194,7 +194,7 @@ private extension CallsView { .font(.system(size: 14, weight: selectedFilter == filter ? .semibold : .regular)) .foregroundStyle( selectedFilter == filter - ? Color.white + ? RosettaColors.Adaptive.text : RosettaColors.Adaptive.textSecondary ) .padding(.horizontal, 12) @@ -202,7 +202,7 @@ private extension CallsView { .background { if selectedFilter == filter { Capsule() - .fill(Color.white.opacity(0.15)) + .fill(RosettaColors.Adaptive.searchBarFill) } } .contentShape(Capsule()) @@ -230,7 +230,7 @@ private extension CallsView { if index < calls.count - 1 { Divider() - .background(Color.white.opacity(0.12)) + .background(RosettaColors.Adaptive.divider) .padding(.leading, 88) } } @@ -274,7 +274,7 @@ private extension CallsView { VStack(alignment: .leading, spacing: 2) { Text(call.name) .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(call.isMissed ? RosettaColors.error : .white) + .foregroundStyle(call.isMissed ? RosettaColors.error : RosettaColors.Adaptive.text) .lineLimit(1) Text(call.subtitleText) diff --git a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift index 6bcbb7f..4b41b6e 100644 --- a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift +++ b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift @@ -82,7 +82,6 @@ struct AttachmentPanelView: View { .presentationDragIndicator(.hidden) .attachmentCornerRadius(20) .attachmentSheetBackground() - .preferredColorScheme(.dark) } // MARK: - Panel Content @@ -94,7 +93,7 @@ struct AttachmentPanelView: View { private var panelContent: some View { ZStack(alignment: .bottom) { if #unavailable(iOS 26) { - Color(hex: 0x1C1C1E).ignoresSafeArea() + RosettaColors.Adaptive.surface.ignoresSafeArea() } VStack(spacing: 0) { toolbar @@ -718,7 +717,7 @@ private struct AttachmentSheetBackgroundModifier: ViewModifier { content } else if #available(iOS 16.4, *) { // iOS < 26: opaque dark background (no glass on older iOS sheets). - content.presentationBackground(Color(hex: 0x1C1C1E)) + content.presentationBackground(RosettaColors.Adaptive.surface) } else { content } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 261e825..53c7af1 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -15,6 +15,7 @@ struct ChatDetailView: View { var onPresentedChange: ((Bool) -> Void)? = nil @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme @StateObject private var viewModel: ChatDetailViewModel init(route: ChatRoute, onPresentedChange: ((Bool) -> Void)? = nil) { @@ -577,12 +578,12 @@ private extension ChatDetailView { TelegramVectorIcon( pathData: TelegramIconPath.backChevron, viewBox: CGSize(width: 11, height: 20), - color: .white + color: RosettaColors.Adaptive.text ) .frame(width: 11, height: 20) .frame(width: 36, height: 36) .contentShape(Circle()) - .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) } + .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } } .buttonStyle(.plain) .accessibilityLabel("Back") @@ -596,7 +597,7 @@ private extension ChatDetailView { .frame(height: 44) .contentShape(Capsule()) .background { - glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white) + glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } } .buttonStyle(.plain) @@ -608,11 +609,11 @@ private extension ChatDetailView { Button { startVoiceCall() } label: { Image(systemName: "phone.fill") .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(RosettaColors.Adaptive.text) .frame(width: 36, height: 36) .contentShape(Circle()) .background { - glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) + glass(shape: .circle, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } } .buttonStyle(.plain) @@ -623,7 +624,7 @@ private extension ChatDetailView { ChatDetailToolbarAvatar(route: route, size: 35) .frame(width: 36, height: 36) .contentShape(Circle()) - .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) } + .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } } } .buttonStyle(.plain) @@ -644,7 +645,7 @@ private extension ChatDetailView { .frame(height: 44) .contentShape(Capsule()) .background { - glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white) + glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } } .buttonStyle(.plain) @@ -656,11 +657,11 @@ private extension ChatDetailView { Button { startVoiceCall() } label: { Image(systemName: "phone.fill") .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(RosettaColors.Adaptive.text) .frame(width: 44, height: 44) .contentShape(Circle()) .background { - glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) + glass(shape: .circle, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } } .buttonStyle(.plain) @@ -671,7 +672,7 @@ private extension ChatDetailView { ChatDetailToolbarAvatar(route: route, size: 38) .frame(width: 44, height: 44) .contentShape(Circle()) - .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) } + .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } } } .buttonStyle(.plain) @@ -683,7 +684,7 @@ private extension ChatDetailView { TelegramVectorIcon( pathData: TelegramIconPath.backChevron, viewBox: CGSize(width: 11, height: 20), - color: .white + color: RosettaColors.Adaptive.text ) .frame(width: 11, height: 20) .frame(width: 36, height: 36) @@ -691,7 +692,7 @@ private extension ChatDetailView { .padding(.horizontal, 4) .contentShape(Capsule()) .background { - glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white) + glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } } @@ -744,20 +745,24 @@ private extension ChatDetailView { // MARK: - Edge Gradients (Telegram-style) + /// Gradient base color β€” adaptive: black in dark, white in light. + private var gradientBase: Color { + colorScheme == .dark ? Color.black : Color.white + } + /// Top: native SwiftUI Material blur with gradient mask β€” blurs content behind it. @ViewBuilder var chatEdgeGradients: some View { if #available(iOS 26, *) { VStack(spacing: 0) { - // Top: dark gradient behind nav bar (same style as iOS < 26). LinearGradient( stops: [ - .init(color: Color.black.opacity(0.85), location: 0.0), - .init(color: Color.black.opacity(0.75), location: 0.2), - .init(color: Color.black.opacity(0.55), location: 0.4), - .init(color: Color.black.opacity(0.3), location: 0.6), - .init(color: Color.black.opacity(0.12), location: 0.78), - .init(color: Color.black.opacity(0.0), location: 1.0), + .init(color: gradientBase.opacity(0.85), location: 0.0), + .init(color: gradientBase.opacity(0.75), location: 0.2), + .init(color: gradientBase.opacity(0.55), location: 0.4), + .init(color: gradientBase.opacity(0.3), location: 0.6), + .init(color: gradientBase.opacity(0.12), location: 0.78), + .init(color: gradientBase.opacity(0.0), location: 1.0), ], startPoint: .top, endPoint: .bottom @@ -766,13 +771,12 @@ private extension ChatDetailView { Spacer() - // Bottom: dark gradient for home indicator area. LinearGradient( stops: [ - .init(color: Color.black.opacity(0.0), location: 0.0), - .init(color: Color.black.opacity(0.3), location: 0.3), - .init(color: Color.black.opacity(0.65), location: 0.6), - .init(color: Color.black.opacity(0.85), location: 1.0), + .init(color: gradientBase.opacity(0.0), location: 0.0), + .init(color: gradientBase.opacity(0.3), location: 0.3), + .init(color: gradientBase.opacity(0.65), location: 0.6), + .init(color: gradientBase.opacity(0.85), location: 1.0), ], startPoint: .top, endPoint: .bottom @@ -783,17 +787,14 @@ private extension ChatDetailView { .allowsHitTesting(false) } else { VStack(spacing: 0) { - // Telegram-style: dark gradient that smoothly fades content into - // the dark background behind the nav bar pills. - // NOT a material blur β€” Telegram uses dark overlay, not light material. LinearGradient( stops: [ - .init(color: Color.black.opacity(0.85), location: 0.0), - .init(color: Color.black.opacity(0.75), location: 0.2), - .init(color: Color.black.opacity(0.55), location: 0.4), - .init(color: Color.black.opacity(0.3), location: 0.6), - .init(color: Color.black.opacity(0.12), location: 0.78), - .init(color: Color.black.opacity(0.0), location: 1.0), + .init(color: gradientBase.opacity(0.85), location: 0.0), + .init(color: gradientBase.opacity(0.75), location: 0.2), + .init(color: gradientBase.opacity(0.55), location: 0.4), + .init(color: gradientBase.opacity(0.3), location: 0.6), + .init(color: gradientBase.opacity(0.12), location: 0.78), + .init(color: gradientBase.opacity(0.0), location: 1.0), ], startPoint: .top, endPoint: .bottom @@ -802,12 +803,11 @@ private extension ChatDetailView { Spacer() - // Bottom: dark gradient for home indicator area below composer. LinearGradient( stops: [ - .init(color: Color.black.opacity(0.0), location: 0.0), - .init(color: Color.black.opacity(0.55), location: 0.35), - .init(color: Color.black.opacity(0.85), location: 1.0), + .init(color: gradientBase.opacity(0.0), location: 0.0), + .init(color: gradientBase.opacity(0.55), location: 0.35), + .init(color: gradientBase.opacity(0.85), location: 1.0), ], startPoint: .top, endPoint: .bottom diff --git a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift index 2ab107a..5c3c160 100644 --- a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift @@ -110,7 +110,7 @@ final class ComposerView: UIView, UITextViewDelegate { pathData: TelegramIconPath.paperclip, viewBox: CGSize(width: 21, height: 24), targetSize: CGSize(width: 21, height: 24), - color: .white + color: .label ) attachButton.layer.addSublayer(attachIcon) attachButton.tag = 1 // for icon centering in layoutSubviews @@ -134,7 +134,7 @@ final class ComposerView: UIView, UITextViewDelegate { replyBar.addSubview(replyBlueBar) replyTitleLabel.font = .systemFont(ofSize: 14, weight: .medium) - replyTitleLabel.textColor = .white + replyTitleLabel.textColor = .label replyTitleLabel.text = "Reply to " replyBar.addSubview(replyTitleLabel) @@ -143,13 +143,13 @@ final class ComposerView: UIView, UITextViewDelegate { replyBar.addSubview(replySenderLabel) replyPreviewLabel.font = .systemFont(ofSize: 14, weight: .regular) - replyPreviewLabel.textColor = .white + replyPreviewLabel.textColor = .label replyPreviewLabel.lineBreakMode = .byTruncatingTail replyBar.addSubview(replyPreviewLabel) let xImage = UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium)) replyCancelButton.setImage(xImage, for: .normal) - replyCancelButton.tintColor = UIColor(white: 1, alpha: 0.6) + replyCancelButton.tintColor = .secondaryLabel replyCancelButton.addTarget(self, action: #selector(replyCancelTapped), for: .touchUpInside) replyBar.addSubview(replyCancelButton) @@ -158,7 +158,7 @@ final class ComposerView: UIView, UITextViewDelegate { // --- Text view --- textView.delegate = self textView.font = .systemFont(ofSize: 17, weight: .regular) - textView.textColor = .white + textView.textColor = .label textView.backgroundColor = .clear textView.tintColor = UIColor(red: 36/255.0, green: 138/255.0, blue: 230/255.0, alpha: 1) textView.isScrollEnabled = false @@ -166,12 +166,12 @@ final class ComposerView: UIView, UITextViewDelegate { textView.textContainer.lineFragmentPadding = 0 textView.autocapitalizationType = .sentences textView.autocorrectionType = .default - textView.keyboardAppearance = .dark + textView.keyboardAppearance = .default textView.returnKeyType = .default textView.placeholderLabel.text = "Message" textView.placeholderLabel.font = .systemFont(ofSize: 17, weight: .regular) - textView.placeholderLabel.textColor = UIColor.white.withAlphaComponent(0.35) + textView.placeholderLabel.textColor = .placeholderText textView.trackingView.onHeightChange = { [weak self] height in guard let self else { return } @@ -185,7 +185,7 @@ final class ComposerView: UIView, UITextViewDelegate { pathData: TelegramIconPath.emojiMoon, viewBox: CGSize(width: 19, height: 19), targetSize: CGSize(width: 19, height: 19), - color: UIColor(white: 1, alpha: 0.6) + color: .secondaryLabel ) emojiButton.layer.addSublayer(emojiIcon) emojiButton.tag = 2 @@ -218,7 +218,7 @@ final class ComposerView: UIView, UITextViewDelegate { pathData: TelegramIconPath.microphone, viewBox: CGSize(width: 18, height: 24), targetSize: CGSize(width: 18, height: 24), - color: .white + color: .label ) micButton.layer.addSublayer(micIcon) micButton.tag = 4 diff --git a/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift index 6131ef4..0fac448 100644 --- a/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift @@ -118,7 +118,6 @@ struct ForwardChatPickerView: View { } } } - .preferredColorScheme(.dark) .presentationBackground(Color.black) .presentationDragIndicator(.hidden) .presentationDetents([.large]) diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index 23d0f59..f25006f 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -511,7 +511,7 @@ struct MessageCellView: View, Equatable { // MARK: - Bubble Background private var incomingBubbleFill: Color { - RosettaColors.adaptive(light: Color(hex: 0x2C2C2E), dark: Color(hex: 0x2C2C2E)) + RosettaColors.Adaptive.messageBubble } @ViewBuilder diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index d31edc1..62eab65 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -14,7 +14,11 @@ final class NativeMessageCell: UICollectionViewCell { // MARK: - Constants private static let outgoingColor = UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1) // #3390EC - private static let incomingColor = UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1) // #2B2B2E + private static let incomingColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1) // #2B2B2E + : UIColor(red: 0.95, green: 0.95, blue: 0.97, alpha: 1) // #F2F2F7 + } private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular) private static let timestampFont = UIFont.systemFont(ofSize: floor(textFont.pointSize * 11.0 / 17.0), weight: .regular) private static let replyNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold) @@ -206,7 +210,7 @@ final class NativeMessageCell: UICollectionViewCell { inlineGlass.autoresizingMask = [.flexibleWidth, .flexibleHeight] dateHeaderContainer.addSubview(inlineGlass) dateHeaderLabel.font = UIFont.systemFont(ofSize: 13, weight: .medium) - dateHeaderLabel.textColor = .white + dateHeaderLabel.textColor = .label dateHeaderLabel.textAlignment = .center dateHeaderContainer.addSubview(dateHeaderLabel) contentView.addSubview(dateHeaderContainer) @@ -215,9 +219,9 @@ final class NativeMessageCell: UICollectionViewCell { bubbleLayer.fillColor = UIColor.clear.cgColor bubbleLayer.fillRule = .nonZero bubbleLayer.shadowColor = UIColor.black.cgColor - bubbleLayer.shadowOpacity = 0.12 - bubbleLayer.shadowRadius = 0.6 - bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4) + bubbleLayer.shadowOpacity = 0 + bubbleLayer.shadowRadius = 0 + bubbleLayer.shadowOffset = .zero bubbleView.layer.insertSublayer(bubbleLayer, at: 0) bubbleOutlineLayer.fillColor = UIColor.clear.cgColor bubbleOutlineLayer.lineWidth = 1.0 / max(UIScreen.main.scale, 1) @@ -359,10 +363,10 @@ final class NativeMessageCell: UICollectionViewCell { fileIconView.addSubview(fileIconSymbolView) fileContainer.addSubview(fileIconView) fileNameLabel.font = Self.fileNameFont - fileNameLabel.textColor = .white + fileNameLabel.textColor = .label fileContainer.addSubview(fileNameLabel) fileSizeLabel.font = Self.fileSizeFont - fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) + fileSizeLabel.textColor = .secondaryLabel fileContainer.addSubview(fileSizeLabel) // Call arrow (small directional arrow left of duration) @@ -514,9 +518,11 @@ final class NativeMessageCell: UICollectionViewCell { if isMediaStatus { timestampLabel.textColor = .white } else { - timestampLabel.textColor = isOutgoing - ? UIColor.white.withAlphaComponent(0.55) - : UIColor.white.withAlphaComponent(0.6) + let isDark = traitCollection.userInterfaceStyle == .dark + let tsAlpha: CGFloat = isOutgoing ? 0.55 : 0.6 + timestampLabel.textColor = (isOutgoing || isDark) + ? UIColor.white.withAlphaComponent(tsAlpha) + : UIColor.black.withAlphaComponent(0.45) } // Delivery checkmarks (Telegram two-node pattern: checkSent + checkRead) @@ -560,6 +566,7 @@ final class NativeMessageCell: UICollectionViewCell { // Reply quote β€” Telegram parity colors if let replyName { replyContainer.isHidden = false + let isDark = traitCollection.userInterfaceStyle == .dark replyContainer.backgroundColor = isOutgoing ? UIColor.white.withAlphaComponent(0.12) : Self.outgoingColor.withAlphaComponent(0.12) @@ -567,7 +574,7 @@ final class NativeMessageCell: UICollectionViewCell { replyNameLabel.text = replyName replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor replyTextLabel.text = replyText ?? "" - replyTextLabel.textColor = .white + replyTextLabel.textColor = (isOutgoing || isDark) ? .white : .darkGray } else { replyContainer.isHidden = true } @@ -627,7 +634,7 @@ final class NativeMessageCell: UICollectionViewCell { // Title (16pt medium β€” Telegram parity) fileNameLabel.font = UIFont.systemFont(ofSize: 16, weight: .medium) - fileNameLabel.textColor = .white + fileNameLabel.textColor = .label if isMissed { fileNameLabel.text = isIncoming ? "Missed Call" : "Cancelled Call" } else { @@ -640,7 +647,7 @@ final class NativeMessageCell: UICollectionViewCell { fileSizeLabel.textColor = UIColor(red: 1.0, green: 0.28, blue: 0.28, alpha: 0.95) } else { fileSizeLabel.text = Self.formattedDuration(seconds: durationSec) - fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) + fileSizeLabel.textColor = .secondaryLabel } // Directional arrow (green/red) @@ -682,7 +689,7 @@ final class NativeMessageCell: UICollectionViewCell { // Telegram parity: accent blue filename (incoming) or white (outgoing) fileNameLabel.textColor = isFileOutgoing ? .white : UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1) fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize) - fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) + fileSizeLabel.textColor = .secondaryLabel callArrowView.isHidden = true callBackButton.isHidden = true } else if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }) { @@ -698,7 +705,7 @@ final class NativeMessageCell: UICollectionViewCell { avatarImageView.isHidden = false fileIconView.isHidden = true fileSizeLabel.text = "Shared profile photo" - fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) + fileSizeLabel.textColor = .secondaryLabel } else { let isOutgoing = currentLayout?.isOutgoing ?? false if isOutgoing { @@ -708,7 +715,7 @@ final class NativeMessageCell: UICollectionViewCell { // Incoming avatar β€” needs download on tap (Android parity) fileSizeLabel.text = "Tap to download" } - fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) + fileSizeLabel.textColor = .secondaryLabel // Show blurhash placeholder (decode async if not cached) let hash = AttachmentPreviewCodec.blurHash(from: avatarAtt.preview) if !hash.isEmpty, let blurImg = Self.blurHashCache.object(forKey: hash as NSString) { @@ -759,7 +766,7 @@ final class NativeMessageCell: UICollectionViewCell { fileIconSymbolView.image = UIImage(systemName: "doc.fill") fileNameLabel.font = Self.fileNameFont fileNameLabel.text = "File" - fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) + fileSizeLabel.textColor = .secondaryLabel callArrowView.isHidden = true callBackButton.isHidden = true } @@ -847,22 +854,11 @@ final class NativeMessageCell: UICollectionViewCell { bubbleOutlineLayer.path = bubbleLayer.path let hasPhotoContent = layout.hasPhoto if hasPhotoContent { - bubbleLayer.shadowOpacity = 0.04 - bubbleLayer.shadowRadius = 0.4 - bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.2) bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor } else if layout.hasTail { - bubbleLayer.shadowOpacity = 0.12 - bubbleLayer.shadowRadius = 0.6 - bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4) bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor } else { - bubbleLayer.shadowOpacity = 0.12 - bubbleLayer.shadowRadius = 0.6 - bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4) - bubbleOutlineLayer.strokeColor = UIColor.black.withAlphaComponent( - layout.isOutgoing ? 0.16 : 0.22 - ).cgColor + bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor } // Text diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 8342b3f..c322fc8 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -962,13 +962,15 @@ final class NativeMessageListController: UIViewController { textLayoutCache.removeAll() return } + let isDark = UserDefaults.standard.object(forKey: "rosetta_dark_mode") as? Bool ?? true let (layouts, textLayouts) = MessageCellLayout.batchCalculate( messages: messages, maxBubbleWidth: config.maxBubbleWidth, currentPublicKey: config.currentPublicKey, opponentPublicKey: config.opponentPublicKey, opponentTitle: config.opponentTitle, - isGroupChat: config.isGroupChat + isGroupChat: config.isGroupChat, + isDarkMode: isDark ) layoutCache = layouts textLayoutCache = textLayouts diff --git a/Rosetta/Features/Chats/ChatDetail/PhotoCollageView.swift b/Rosetta/Features/Chats/ChatDetail/PhotoCollageView.swift index 64a825b..db8bc81 100644 --- a/Rosetta/Features/Chats/ChatDetail/PhotoCollageView.swift +++ b/Rosetta/Features/Chats/ChatDetail/PhotoCollageView.swift @@ -33,7 +33,7 @@ struct PhotoCollageView: View { /// Bubble fill color β€” used as gap color between collage cells. private var bubbleColor: Color { - outgoing ? RosettaColors.figmaBlue : Color(hex: 0x2C2C2E) + outgoing ? RosettaColors.figmaBlue : RosettaColors.Adaptive.messageBubble } /// Maximum collage height. diff --git a/Rosetta/Features/Chats/ChatDetail/PhotoPreviewView.swift b/Rosetta/Features/Chats/ChatDetail/PhotoPreviewView.swift index 2374df9..cc7443e 100644 --- a/Rosetta/Features/Chats/ChatDetail/PhotoPreviewView.swift +++ b/Rosetta/Features/Chats/ChatDetail/PhotoPreviewView.swift @@ -61,7 +61,6 @@ struct PhotoPreviewView: View { } .offset(y: dragOffset) .gesture(dismissDragGesture) - .preferredColorScheme(.dark) .task { await loadFullResolutionImage() } diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 6b0a718..66224f4 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -47,7 +47,7 @@ struct ChatListView: View { .padding(.bottom, 8) .background( (hasPinnedChats && !isSearchActive - ? RosettaColors.Dark.pinnedSectionBackground + ? RosettaColors.Adaptive.pinnedSectionBackground : Color.clear ).ignoresSafeArea(.all, edges: .top) ) @@ -121,7 +121,6 @@ struct ChatListView: View { } } .presentationDetents([.large]) - .preferredColorScheme(.dark) } .sheet(isPresented: $showJoinGroupSheet) { NavigationStack { @@ -133,7 +132,6 @@ struct ChatListView: View { } } .presentationDetents([.large]) - .preferredColorScheme(.dark) } .onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in guard let route = notification.object as? ChatRoute else { return } @@ -238,14 +236,14 @@ private extension ChatListView { .background { if isSearchActive { RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(Color.white.opacity(0.08)) + .fill(RosettaColors.Adaptive.searchBarFill) .overlay { RoundedRectangle(cornerRadius: 24, style: .continuous) - .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5) + .strokeBorder(RosettaColors.Adaptive.searchBarBorder, lineWidth: 0.5) } } else { RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(RosettaColors.Adaptive.backgroundSecondary) + .fill(RosettaColors.Adaptive.searchBarFill) } } .onChange(of: isSearchFocused) { _, focused in @@ -266,17 +264,17 @@ private extension ChatListView { .resizable() .scaledToFit() .frame(width: 19, height: 19) - .foregroundStyle(.white) + .foregroundStyle(RosettaColors.Adaptive.text) .frame(width: 36, height: 36) .padding(3) } .buttonStyle(.plain) .background { Circle() - .fill(Color.white.opacity(0.08)) + .fill(RosettaColors.Adaptive.searchBarFill) .overlay { Circle() - .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5) + .strokeBorder(RosettaColors.Adaptive.searchBarBorder, lineWidth: 0.5) } } .transition(.opacity.combined(with: .scale(scale: 0.5))) @@ -507,10 +505,10 @@ private struct SyncAwareEmptyState: View { VStack(spacing: 16) { Spacer().frame(height: 120) ProgressView() - .tint(.white) + .tint(RosettaColors.Adaptive.textSecondary) Text("Syncing conversations…") .font(.system(size: 15)) - .foregroundStyle(Color.white.opacity(0.5)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) Spacer() } .frame(maxWidth: .infinity) @@ -680,8 +678,8 @@ private struct ChatListDialogContent: View { if !pinned.isEmpty { ForEach(pinned, id: \.id) { dialog in chatRow(dialog, isFirst: dialog.id == pinned.first?.id && requestsCount == 0) - .environment(\.rowBackgroundColor, RosettaColors.Dark.pinnedSectionBackground) - .listRowBackground(RosettaColors.Dark.pinnedSectionBackground) + .environment(\.rowBackgroundColor, RosettaColors.Adaptive.pinnedSectionBackground) + .listRowBackground(RosettaColors.Adaptive.pinnedSectionBackground) } } ForEach(unpinned, id: \.id) { dialog in diff --git a/Rosetta/Features/Chats/ChatList/DeviceConfirmView.swift b/Rosetta/Features/Chats/ChatList/DeviceConfirmView.swift index 773a52c..b2b1136 100644 --- a/Rosetta/Features/Chats/ChatList/DeviceConfirmView.swift +++ b/Rosetta/Features/Chats/ChatList/DeviceConfirmView.swift @@ -11,7 +11,7 @@ struct DeviceConfirmView: View { var body: some View { ZStack { // Full black background covering everything - RosettaColors.Dark.background + RosettaColors.Adaptive.background .ignoresSafeArea() VStack(spacing: 0) { diff --git a/Rosetta/Features/Groups/GroupInfoView.swift b/Rosetta/Features/Groups/GroupInfoView.swift index 20d759f..55db14f 100644 --- a/Rosetta/Features/Groups/GroupInfoView.swift +++ b/Rosetta/Features/Groups/GroupInfoView.swift @@ -16,7 +16,7 @@ struct GroupInfoView: View { var body: some View { ZStack { - RosettaColors.Dark.background.ignoresSafeArea() + RosettaColors.Adaptive.background.ignoresSafeArea() ScrollView { VStack(spacing: 16) { diff --git a/Rosetta/Features/Groups/GroupJoinView.swift b/Rosetta/Features/Groups/GroupJoinView.swift index 63637d8..eb9cf37 100644 --- a/Rosetta/Features/Groups/GroupJoinView.swift +++ b/Rosetta/Features/Groups/GroupJoinView.swift @@ -13,7 +13,7 @@ struct GroupJoinView: View { var body: some View { ZStack { - RosettaColors.Dark.background.ignoresSafeArea() + RosettaColors.Adaptive.background.ignoresSafeArea() VStack(spacing: 24) { Spacer().frame(height: 20) diff --git a/Rosetta/Features/Groups/GroupSetupView.swift b/Rosetta/Features/Groups/GroupSetupView.swift index 4f0c7e2..f086194 100644 --- a/Rosetta/Features/Groups/GroupSetupView.swift +++ b/Rosetta/Features/Groups/GroupSetupView.swift @@ -21,7 +21,7 @@ struct GroupSetupView: View { var body: some View { ZStack { - RosettaColors.Dark.background.ignoresSafeArea() + RosettaColors.Adaptive.background.ignoresSafeArea() VStack(spacing: 0) { stepContent diff --git a/Rosetta/Features/Onboarding/OnboardingView.swift b/Rosetta/Features/Onboarding/OnboardingView.swift index c5756ca..196c57c 100644 --- a/Rosetta/Features/Onboarding/OnboardingView.swift +++ b/Rosetta/Features/Onboarding/OnboardingView.swift @@ -46,7 +46,6 @@ struct OnboardingView: View { } } } - .preferredColorScheme(.dark) .statusBarHidden(false) } } diff --git a/Rosetta/Features/Settings/ProfileEditView.swift b/Rosetta/Features/Settings/ProfileEditView.swift index b31b87a..505e099 100644 --- a/Rosetta/Features/Settings/ProfileEditView.swift +++ b/Rosetta/Features/Settings/ProfileEditView.swift @@ -242,5 +242,4 @@ private extension ProfileEditView { } .background(RosettaColors.Adaptive.background) } - .preferredColorScheme(.dark) } diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift index f990410..19fd8b3 100644 --- a/Rosetta/Features/Settings/SettingsView.swift +++ b/Rosetta/Features/Settings/SettingsView.swift @@ -179,14 +179,8 @@ struct SettingsView: View { .glassCapsule() .disabled(isSaving) } else { - Button {} label: { - Image(systemName: "qrcode") - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(RosettaColors.Adaptive.text) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .glassCircle() + DarkModeButton() + .glassCircle() } } @@ -355,7 +349,6 @@ struct SettingsView: View { if BiometricAuthManager.shared.isBiometricAvailable { biometricCard } - themeCard safetyCard rosettaPowerFooter @@ -588,15 +581,6 @@ struct SettingsView: View { .padding(.top, 16) } - private var themeCard: some View { - settingsCardWithSubtitle( - icon: "paintbrush.fill", - title: "Theme", - color: .indigo, - subtitle: "You can change the theme." - ) {} - } - private var safetyCard: some View { VStack(alignment: .leading, spacing: 8) { SettingsCard { diff --git a/Rosetta/Resources/Lottie/Images/back_5.png b/Rosetta/Resources/Lottie/Images/back_5.png deleted file mode 100644 index 3ec7b78..0000000 Binary files a/Rosetta/Resources/Lottie/Images/back_5.png and /dev/null differ diff --git a/Rosetta/Resources/Lottie/Images/wallpaper_dark_03.png b/Rosetta/Resources/Lottie/Images/wallpaper_dark_03.png new file mode 100644 index 0000000..d6144df Binary files /dev/null and b/Rosetta/Resources/Lottie/Images/wallpaper_dark_03.png differ diff --git a/Rosetta/Resources/Lottie/Images/wallpaper_light_02.png b/Rosetta/Resources/Lottie/Images/wallpaper_light_02.png new file mode 100644 index 0000000..dd3c8be Binary files /dev/null and b/Rosetta/Resources/Lottie/Images/wallpaper_light_02.png differ diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 9ecbdc8..0de65e2 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -651,7 +651,7 @@ struct RosettaApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate init() { - UIWindow.appearance().backgroundColor = .black + UIWindow.appearance().backgroundColor = .systemBackground // Detect fresh install: UserDefaults are wiped on uninstall, Keychain is not. // If this is the first launch after install, clear any stale Keychain data. @@ -671,28 +671,31 @@ struct RosettaApp: App { @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @AppStorage("isLoggedIn") private var isLoggedIn = false @AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false + @AppStorage("rosetta_dark_mode") private var isDarkMode: Bool = true @State private var appState: AppState? @State private var transitionOverlay: Bool = false var body: some Scene { WindowGroup { - ZStack { - RosettaColors.Dark.background - .ignoresSafeArea() + DarkModeWrapper { + ZStack { + RosettaColors.Adaptive.background + .ignoresSafeArea() - if let appState { - rootView(for: appState) + if let appState { + rootView(for: appState) + } + + // Fade-through-black overlay for smooth screen transitions. + // Avoids UIKit-hosted Lottie views fighting SwiftUI opacity transitions. + Color.black + .ignoresSafeArea() + .opacity(transitionOverlay ? 1 : 0) + .allowsHitTesting(transitionOverlay) + .animation(.easeInOut(duration: 0.035), value: transitionOverlay) } - - // Fade-through-black overlay for smooth screen transitions. - // Avoids UIKit-hosted Lottie views fighting SwiftUI opacity transitions. - Color.black - .ignoresSafeArea() - .opacity(transitionOverlay ? 1 : 0) - .allowsHitTesting(transitionOverlay) - .animation(.easeInOut(duration: 0.035), value: transitionOverlay) } - .preferredColorScheme(.dark) + .preferredColorScheme(isDarkMode ? .dark : .light) .onAppear { if appState == nil { appState = initialState()