diff --git a/Rosetta/Assets.xcassets/Wallpaper3.imageset/wallpaper_light_03.png b/Rosetta/Assets.xcassets/Wallpaper3.imageset/wallpaper_light_03.png index c69cdca..7abdd15 100644 Binary files a/Rosetta/Assets.xcassets/Wallpaper3.imageset/wallpaper_light_03.png and b/Rosetta/Assets.xcassets/Wallpaper3.imageset/wallpaper_light_03.png differ diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift index 53c7cb1..013682a 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift @@ -56,6 +56,15 @@ final class ChatDetailViewController: UIViewController { private var titlePill: UIControl! private var avatarButton: UIControl! + // MARK: - Wallpaper + + private let wallpaperImageView: UIImageView = { + let iv = UIImageView() + iv.contentMode = .scaleAspectFill + iv.clipsToBounds = true + return iv + }() + // MARK: - Edge Effects private let topEdgeEffectView = VariableBlurEdgeView(frame: .zero) @@ -84,6 +93,7 @@ final class ChatDetailViewController: UIViewController { super.viewDidLoad() view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + setupWallpaper() setupMessageListController() setupNavigationChrome() setupEdgeEffects() @@ -282,6 +292,44 @@ final class ChatDetailViewController: UIViewController { ) } + // MARK: - Wallpaper + + private func setupWallpaper() { + wallpaperImageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(wallpaperImageView) + NSLayoutConstraint.activate([ + wallpaperImageView.topAnchor.constraint(equalTo: view.topAnchor), + wallpaperImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + wallpaperImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + wallpaperImageView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + updateWallpaperImage() + + NotificationCenter.default.addObserver( + self, + selector: #selector(wallpaperDidChange), + name: UserDefaults.didChangeNotification, + object: nil + ) + } + + private func updateWallpaperImage() { + let wallpaperId = UserDefaults.standard.string(forKey: "rosetta_wallpaper_id") ?? "default" + let option = WallpaperOption.allOptions.first(where: { $0.id == wallpaperId }) + ?? WallpaperOption.allOptions[0] + + switch option.style { + case .none: + wallpaperImageView.image = nil + case .image(let assetName): + wallpaperImageView.image = UIImage(named: assetName) + } + } + + @objc private func wallpaperDidChange() { + updateWallpaperImage() + } + // MARK: - Edge Effects private func setupEdgeEffects() { @@ -337,6 +385,7 @@ final class ChatDetailViewController: UIViewController { let isDark = traitCollection.userInterfaceStyle == .dark topEdgeEffectView.setTintColor(isDark ? .black : .white) updateBottomGradientColors() + updateWallpaperImage() } } diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift index cd91582..813df89 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift @@ -203,9 +203,6 @@ final class ChatListRootViewController: UIViewController, UINavigationController } private func updateNavigationBlurHeight() { - // Telegram: edgeEffectHeight = componentHeight + 14 - searchBarExpansion - // Result: edge effect covers toolbar area + 14pt ONLY (not search bar). - // Search bar has its own background, doesn't need blur coverage. navigationBlurHeightConstraint?.constant = max(0, headerTotalHeight + 14.0) } @@ -222,9 +219,10 @@ final class ChatListRootViewController: UIViewController, UINavigationController editButtonControl.isUserInteractionEnabled = true rightButtonsControl.isUserInteractionEnabled = true toolbarTitleView.isUserInteractionEnabled = true - // Restore search bar position, blur, and tear down overlay + // Restore search bar position, blur, list, and tear down overlay searchHeaderTopConstraint?.constant = headerBarHeight + searchTopSpacing navigationBlurView.isHidden = false + listController.view.isHidden = false teardownSearchOverlayImmediately() } } @@ -309,9 +307,9 @@ final class ChatListRootViewController: UIViewController, UINavigationController searchHeaderView.onActiveChanged = { [weak self] active in guard let self else { return } self.applySearchExpansion(1.0, animated: true) - // Hide blur when search active (search bar moves up, no blur needed) - self.updateNavigationBarBlur(progress: active ? 0.0 : (1.0 - self.lastSearchExpansion)) self.navigationBlurView.isHidden = active + self.listController.view.isHidden = active + self.updateNavigationBarBlur(progress: active ? 0.0 : (1.0 - self.lastSearchExpansion)) self.animateToolbarForSearch(active: active) self.animateSearchBarPosition(active: active) if active { @@ -518,7 +516,7 @@ final class ChatListRootViewController: UIViewController, UINavigationController view.insertSubview(bg, belowSubview: searchHeaderView) NSLayoutConstraint.activate([ - bg.topAnchor.constraint(equalTo: view.topAnchor), + bg.topAnchor.constraint(equalTo: searchHeaderView.bottomAnchor, constant: searchBottomSpacing), bg.leadingAnchor.constraint(equalTo: view.leadingAnchor), bg.trailingAnchor.constraint(equalTo: view.trailingAnchor), bg.bottomAnchor.constraint(equalTo: view.bottomAnchor), @@ -534,25 +532,12 @@ final class ChatListRootViewController: UIViewController, UINavigationController hosting.didMove(toParent: self) NSLayoutConstraint.activate([ - // Content starts below search bar (not behind it) - hosting.view.topAnchor.constraint(equalTo: searchHeaderView.bottomAnchor, constant: searchBottomSpacing), + hosting.view.topAnchor.constraint(equalTo: bg.topAnchor), hosting.view.leadingAnchor.constraint(equalTo: bg.leadingAnchor), hosting.view.trailingAnchor.constraint(equalTo: bg.trailingAnchor), hosting.view.bottomAnchor.constraint(equalTo: bg.bottomAnchor), ]) - // Edge fade gradient below search bar (Telegram-style) - let edgeFade = CAGradientLayer() - edgeFade.colors = [ - UIColor(RosettaColors.Adaptive.background).cgColor, - UIColor(RosettaColors.Adaptive.background).withAlphaComponent(0).cgColor, - ] - edgeFade.locations = [0, 1] - edgeFade.startPoint = CGPoint(x: 0.5, y: 0) - edgeFade.endPoint = CGPoint(x: 0.5, y: 1) - edgeFade.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 14) - hosting.view.layer.addSublayer(edgeFade) - searchOverlayView = bg searchContentHosting = hosting @@ -1261,8 +1246,8 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate { // Telegram-style circular X button: 44pt, glass material cancelButton.translatesAutoresizingMaskIntoConstraints = false - let xConfig = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium) - cancelButton.setImage(UIImage(systemName: "xmark", withConfiguration: xConfig), for: .normal) + // Telegram exact: custom drawn X — 2pt lineWidth, round cap, 40×40 canvas + cancelButton.setImage(Self.telegramCloseIcon(size: 40, color: .white), for: .normal) cancelButton.setTitle(nil, for: .normal) cancelButton.clipsToBounds = false cancelButton.backgroundColor = .clear @@ -1408,6 +1393,24 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate { inlineClearButton.isUserInteractionEnabled = inlineClearButton.alpha > 0 } + /// Telegram exact close icon: two diagonal lines, 2pt width, round cap. + /// Source: SearchBarPlaceholderNode.swift — lines from (12,12)→(28,28) in 40×40 canvas. + private static func telegramCloseIcon(size: CGFloat, color: UIColor) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) + return renderer.image { ctx in + let cg = ctx.cgContext + cg.setStrokeColor(color.cgColor) + cg.setLineWidth(2.0) + cg.setLineCap(.round) + let inset: CGFloat = size * 0.3 // 12/40 = 0.3 + cg.move(to: CGPoint(x: inset, y: inset)) + cg.addLine(to: CGPoint(x: size - inset, y: size - inset)) + cg.move(to: CGPoint(x: size - inset, y: inset)) + cg.addLine(to: CGPoint(x: inset, y: size - inset)) + cg.strokePath() + }.withRenderingMode(.alwaysTemplate) + } + @objc private func handleCapsuleTapped() { guard !isSearchActive else { return } setSearchActive(true, animated: true) @@ -1614,7 +1617,7 @@ private final class ChatListToolbarDualActionButton: UIView { private let backgroundView = ChatListToolbarGlassCapsuleView() private let addButton = UIButton(type: .system) private let composeButton = UIButton(type: .system) - private let dividerView = UIView() + // dividerView removed per Telegram parity override init(frame: CGRect) { super.init(frame: frame) @@ -1626,18 +1629,18 @@ private final class ChatListToolbarDualActionButton: UIView { addButton.translatesAutoresizingMaskIntoConstraints = false composeButton.translatesAutoresizingMaskIntoConstraints = false - dividerView.translatesAutoresizingMaskIntoConstraints = false addButton.tintColor = UIColor(RosettaColors.Adaptive.text) composeButton.tintColor = UIColor(RosettaColors.Adaptive.text) addButton.accessibilityLabel = "Add" composeButton.accessibilityLabel = "Compose" - let iconConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium) - let addIcon = UIImage(named: "toolbar-add-chat")?.withRenderingMode(.alwaysTemplate) - ?? UIImage(systemName: "plus", withConfiguration: iconConfig) - let composeIcon = UIImage(named: "toolbar-compose")?.withRenderingMode(.alwaysTemplate) - ?? UIImage(systemName: "square.and.pencil", withConfiguration: iconConfig) + let iconConfig = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) + let targetIconSize = CGSize(width: 19, height: 19) + let addIcon = (UIImage(named: "toolbar-add-chat")?.withRenderingMode(.alwaysTemplate) + ?? UIImage(systemName: "plus", withConfiguration: iconConfig))?.scaledTo(targetIconSize) + let composeIcon = (UIImage(named: "toolbar-compose")?.withRenderingMode(.alwaysTemplate) + ?? UIImage(systemName: "square.and.pencil", withConfiguration: iconConfig))?.scaledTo(targetIconSize) addButton.setImage(addIcon, for: .normal) composeButton.setImage(composeIcon, for: .normal) @@ -1648,11 +1651,8 @@ private final class ChatListToolbarDualActionButton: UIView { addButton.addTarget(self, action: #selector(handleTouchUp(_:)), for: [.touchUpInside, .touchCancel, .touchDragExit]) composeButton.addTarget(self, action: #selector(handleTouchUp(_:)), for: [.touchUpInside, .touchCancel, .touchDragExit]) - dividerView.backgroundColor = UIColor.white.withAlphaComponent(0.16) - addSubview(addButton) addSubview(composeButton) - addSubview(dividerView) NSLayoutConstraint.activate([ backgroundView.topAnchor.constraint(equalTo: topAnchor), @@ -1663,20 +1663,15 @@ private final class ChatListToolbarDualActionButton: UIView { addButton.leadingAnchor.constraint(equalTo: leadingAnchor), addButton.topAnchor.constraint(equalTo: topAnchor), addButton.bottomAnchor.constraint(equalTo: bottomAnchor), - addButton.widthAnchor.constraint(equalToConstant: 44), + addButton.widthAnchor.constraint(equalToConstant: 36), composeButton.trailingAnchor.constraint(equalTo: trailingAnchor), composeButton.topAnchor.constraint(equalTo: topAnchor), composeButton.bottomAnchor.constraint(equalTo: bottomAnchor), - composeButton.widthAnchor.constraint(equalToConstant: 44), - - dividerView.centerXAnchor.constraint(equalTo: centerXAnchor), - dividerView.centerYAnchor.constraint(equalTo: centerYAnchor), - dividerView.widthAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale), - dividerView.heightAnchor.constraint(equalToConstant: 20), + composeButton.widthAnchor.constraint(equalToConstant: 36), heightAnchor.constraint(equalToConstant: 44), - widthAnchor.constraint(equalToConstant: 88), + widthAnchor.constraint(equalToConstant: 72), ]) self.frame = CGRect(origin: .zero, size: intrinsicContentSize) @@ -1687,7 +1682,7 @@ private final class ChatListToolbarDualActionButton: UIView { } override var intrinsicContentSize: CGSize { - CGSize(width: 88, height: 44) + CGSize(width: 72, height: 44) } @objc private func handleAddTapped() { @@ -1898,3 +1893,12 @@ private final class ChatListToolbarArcSpinnerView: UIView { layer.removeAnimation(forKey: animationKey) } } + +private extension UIImage { + func scaledTo(_ size: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + self.draw(in: CGRect(origin: .zero, size: size)) + }.withRenderingMode(renderingMode) + } +} diff --git a/Rosetta/Features/Settings/AppearanceView.swift b/Rosetta/Features/Settings/AppearanceView.swift index ee007e6..8ff4dab 100644 --- a/Rosetta/Features/Settings/AppearanceView.swift +++ b/Rosetta/Features/Settings/AppearanceView.swift @@ -1,3 +1,4 @@ +import UIKit import SwiftUI // MARK: - Wallpaper Definitions @@ -50,137 +51,364 @@ enum ThemeMode: String, CaseIterable { } } -// MARK: - Appearance View +// MARK: - Appearance ViewController -struct AppearanceView: View { - @AppStorage("rosetta_wallpaper_id") private var selectedWallpaperId: String = "default" - @AppStorage("rosetta_theme_mode") private var themeModeRaw: String = "dark" +final class AppearanceViewController: UIViewController { + + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + + // Header + private let headerBarHeight: CGFloat = 44 + private var backButton: UIControl! + private let titleLabel = UILabel() + + // Chat preview + private let previewContainer = UIView() + private let previewWallpaperView = UIImageView() + private let incomingBubble = UIView() + private let outgoingBubble = UIView() + + // Theme section + private let themeSectionLabel = UILabel() + private let themeCard = UIView() + private var themeButtons: [ThemeMode: UIButton] = [:] + private var themeDividers: [UIView] = [] + + // Wallpaper section + private let wallpaperSectionLabel = UILabel() + private var wallpaperCells: [String: WallpaperCellView] = [:] + + // State + private var selectedWallpaperId: String { + get { UserDefaults.standard.string(forKey: "rosetta_wallpaper_id") ?? "default" } + set { UserDefaults.standard.set(newValue, forKey: "rosetta_wallpaper_id") } + } + + private var themeModeRaw: String { + get { UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "dark" } + set { UserDefaults.standard.set(newValue, forKey: "rosetta_theme_mode") } + } private var themeMode: ThemeMode { ThemeMode(rawValue: themeModeRaw) ?? .dark } - private let columns = Array(repeating: GridItem(.flexible(), spacing: 10), count: 3) + // MARK: - Init - var body: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 24) { - chatPreviewSection - themeSection - wallpaperSection - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 100) + init() { + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + navigationController?.setNavigationBarHidden(true, animated: false) + + setupScrollView() + setupHeader() + setupChatPreview() + setupThemeSection() + setupWallpaperSection() + updateSelection() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + setupFullWidthSwipeBack() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + layoutHeader() + layoutThemeDividers() + } + + private func layoutThemeDividers() { + let w = themeCard.bounds.width + let h = themeCard.bounds.height + let dividerH: CGFloat = 24 + let y = (h - dividerH) / 2 + for (i, divider) in themeDividers.enumerated() { + let x = w * CGFloat(i + 1) / 3.0 + divider.frame = CGRect(x: x, y: y, width: 0.5, height: dividerH) } - .background(RosettaColors.Adaptive.background) - .scrollContentBackground(.hidden) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .principal) { - Text("Appearance") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - } - } - .toolbarBackground(.hidden, for: .navigationBar) + } + + // MARK: - Full-Width Swipe Back + + private var addedSwipeBackGesture = false + + private func setupFullWidthSwipeBack() { + guard !addedSwipeBackGesture else { return } + addedSwipeBackGesture = true + + guard let nav = navigationController, + let edgeGesture = nav.interactivePopGestureRecognizer, + let targets = edgeGesture.value(forKey: "targets") as? NSArray, + targets.count > 0 else { return } + + edgeGesture.isEnabled = true + + let fullWidthGesture = UIPanGestureRecognizer() + fullWidthGesture.setValue(targets, forKey: "targets") + fullWidthGesture.delegate = self + nav.view.addGestureRecognizer(fullWidthGesture) + } + + // MARK: - Header + + private func setupHeader() { + let back = AppearanceBackButton() + back.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + back.layer.zPosition = 55 + view.addSubview(back) + backButton = back + + titleLabel.text = "Appearance" + titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = UIColor(RosettaColors.Adaptive.text) + titleLabel.textAlignment = .center + titleLabel.layer.zPosition = 55 + view.addSubview(titleLabel) + } + + private func layoutHeader() { + let safeTop = view.safeAreaInsets.top + let centerY = safeTop + headerBarHeight * 0.5 + + let sideMargin: CGFloat = 8 + let backSize: CGFloat = 44 + backButton.frame = CGRect( + x: sideMargin, + y: centerY - backSize * 0.5, + width: backSize, + height: backSize + ) + + let titleWidth: CGFloat = 200 + titleLabel.frame = CGRect( + x: (view.bounds.width - titleWidth) * 0.5, + y: centerY - 22, + width: titleWidth, + height: 44 + ) + + // Update scroll inset + let headerTotal = safeTop + headerBarHeight + 8 + scrollView.contentInset.top = headerTotal + scrollView.verticalScrollIndicatorInsets.top = headerTotal + } + + @objc private func backTapped() { + navigationController?.popViewController(animated: true) + } + + // MARK: - Scroll View + + private func setupScrollView() { + scrollView.showsVerticalScrollIndicator = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + contentStack.axis = .vertical + contentStack.spacing = 24 + contentStack.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(contentStack) + NSLayoutConstraint.activate([ + contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16), + contentStack.leadingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.leadingAnchor, constant: 16), + contentStack.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor, constant: -16), + contentStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -100), + ]) } // MARK: - Chat Preview - private var chatPreviewSection: some View { - VStack(spacing: 0) { - ZStack { - // Wallpaper background - wallpaperPreview(for: selectedWallpaperId) - .frame(height: 200) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + private func setupChatPreview() { + previewContainer.layer.cornerRadius = 20 + previewContainer.layer.cornerCurve = .continuous + previewContainer.clipsToBounds = true - // Sample messages overlay - VStack(spacing: 6) { - Spacer() + previewWallpaperView.contentMode = .scaleAspectFill + previewWallpaperView.clipsToBounds = true + previewWallpaperView.translatesAutoresizingMaskIntoConstraints = false + previewContainer.addSubview(previewWallpaperView) + NSLayoutConstraint.activate([ + previewWallpaperView.topAnchor.constraint(equalTo: previewContainer.topAnchor), + previewWallpaperView.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor), + previewWallpaperView.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor), + previewWallpaperView.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor), + ]) - // Incoming message - HStack { - Text("Hey! How's it going?") - .font(.system(size: 15)) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color(hex: 0x2A2A2A)) - ) - Spacer() - } + // Incoming bubble + let inLabel = UILabel() + inLabel.text = "Hey! How's it going?" + inLabel.font = .systemFont(ofSize: 15) + inLabel.textColor = .white + inLabel.translatesAutoresizingMaskIntoConstraints = false - // Outgoing message - HStack { - Spacer() - Text("Great, thanks!") - .font(.system(size: 15)) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(RosettaColors.primaryBlue) - ) - } + incomingBubble.backgroundColor = UIColor(red: 42/255, green: 42/255, blue: 42/255, alpha: 1) + incomingBubble.layer.cornerRadius = 18 + incomingBubble.layer.cornerCurve = .continuous + incomingBubble.translatesAutoresizingMaskIntoConstraints = false + incomingBubble.addSubview(inLabel) + NSLayoutConstraint.activate([ + inLabel.topAnchor.constraint(equalTo: incomingBubble.topAnchor, constant: 8), + inLabel.leadingAnchor.constraint(equalTo: incomingBubble.leadingAnchor, constant: 14), + inLabel.trailingAnchor.constraint(equalTo: incomingBubble.trailingAnchor, constant: -14), + inLabel.bottomAnchor.constraint(equalTo: incomingBubble.bottomAnchor, constant: -8), + ]) + previewContainer.addSubview(incomingBubble) - Spacer() - .frame(height: 12) - } - .padding(.horizontal, 12) - } - } + // Outgoing bubble + let outLabel = UILabel() + outLabel.text = "Great, thanks!" + outLabel.font = .systemFont(ofSize: 15) + outLabel.textColor = .white + outLabel.translatesAutoresizingMaskIntoConstraints = false + + outgoingBubble.backgroundColor = UIColor(RosettaColors.primaryBlue) + outgoingBubble.layer.cornerRadius = 18 + outgoingBubble.layer.cornerCurve = .continuous + outgoingBubble.translatesAutoresizingMaskIntoConstraints = false + outgoingBubble.addSubview(outLabel) + NSLayoutConstraint.activate([ + outLabel.topAnchor.constraint(equalTo: outgoingBubble.topAnchor, constant: 8), + outLabel.leadingAnchor.constraint(equalTo: outgoingBubble.leadingAnchor, constant: 14), + outLabel.trailingAnchor.constraint(equalTo: outgoingBubble.trailingAnchor, constant: -14), + outLabel.bottomAnchor.constraint(equalTo: outgoingBubble.bottomAnchor, constant: -8), + ]) + previewContainer.addSubview(outgoingBubble) + + // Layout bubbles + NSLayoutConstraint.activate([ + incomingBubble.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 12), + incomingBubble.bottomAnchor.constraint(equalTo: outgoingBubble.topAnchor, constant: -6), + + outgoingBubble.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -12), + outgoingBubble.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor, constant: -12), + ]) + + previewContainer.translatesAutoresizingMaskIntoConstraints = false + contentStack.addArrangedSubview(previewContainer) + previewContainer.heightAnchor.constraint(equalToConstant: 200).isActive = true } // MARK: - Theme Section - private var themeSection: some View { - VStack(alignment: .leading, spacing: 10) { - Text("COLOR THEME") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - .padding(.horizontal, 4) + private func setupThemeSection() { + themeSectionLabel.text = "COLOR THEME" + themeSectionLabel.font = .systemFont(ofSize: 13, weight: .medium) + themeSectionLabel.textColor = UIColor(RosettaColors.Adaptive.textSecondary) - SettingsCard { - HStack(spacing: 0) { - ForEach(ThemeMode.allCases, id: \.self) { mode in - themeButton(mode) - if mode != ThemeMode.allCases.last { - Divider() - .frame(height: 24) - .foregroundStyle(RosettaColors.Adaptive.divider) - } - } - } - .frame(height: 52) - } + let sectionStack = UIStackView() + sectionStack.axis = .vertical + sectionStack.spacing = 10 + + let labelWrapper = UIView() + themeSectionLabel.translatesAutoresizingMaskIntoConstraints = false + labelWrapper.addSubview(themeSectionLabel) + NSLayoutConstraint.activate([ + themeSectionLabel.leadingAnchor.constraint(equalTo: labelWrapper.leadingAnchor, constant: 4), + themeSectionLabel.topAnchor.constraint(equalTo: labelWrapper.topAnchor), + themeSectionLabel.bottomAnchor.constraint(equalTo: labelWrapper.bottomAnchor), + ]) + sectionStack.addArrangedSubview(labelWrapper) + + // Card + themeCard.backgroundColor = UIColor { $0.userInterfaceStyle == .dark + ? UIColor(red: 28/255, green: 28/255, blue: 30/255, alpha: 1) + : UIColor(red: 242/255, green: 242/255, blue: 247/255, alpha: 1) } + themeCard.layer.cornerRadius = 26 + themeCard.layer.cornerCurve = .continuous + + let buttonStack = UIStackView() + buttonStack.axis = .horizontal + buttonStack.distribution = .fillEqually + buttonStack.translatesAutoresizingMaskIntoConstraints = false + themeCard.addSubview(buttonStack) + NSLayoutConstraint.activate([ + buttonStack.topAnchor.constraint(equalTo: themeCard.topAnchor), + buttonStack.leadingAnchor.constraint(equalTo: themeCard.leadingAnchor), + buttonStack.trailingAnchor.constraint(equalTo: themeCard.trailingAnchor), + buttonStack.bottomAnchor.constraint(equalTo: themeCard.bottomAnchor), + ]) + + for (index, mode) in ThemeMode.allCases.enumerated() { + let btn = UIButton(type: .system) + btn.tag = index + btn.addTarget(self, action: #selector(themeButtonTapped(_:)), for: .touchUpInside) + + var config = UIButton.Configuration.plain() + config.image = UIImage(systemName: mode.iconName)?.withConfiguration( + UIImage.SymbolConfiguration(pointSize: 14) + ) + config.title = mode.label + config.imagePadding = 8 + btn.configuration = config + btn.tintColor = UIColor(RosettaColors.Adaptive.textSecondary) + + themeButtons[mode] = btn + buttonStack.addArrangedSubview(btn) + } + + // Dividers — added to themeCard directly (not the stack), positioned in layoutSubviews + for _ in 0..<2 { + let divider = UIView() + divider.backgroundColor = UIColor(RosettaColors.Adaptive.divider) + themeCard.addSubview(divider) + themeDividers.append(divider) + } + + themeCard.translatesAutoresizingMaskIntoConstraints = false + themeCard.heightAnchor.constraint(equalToConstant: 52).isActive = true + sectionStack.addArrangedSubview(themeCard) + contentStack.addArrangedSubview(sectionStack) } - private func themeButton(_ mode: ThemeMode) -> some View { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - themeModeRaw = mode.rawValue - } - applyThemeMode(mode) - } label: { - HStack(spacing: 8) { - Image(systemName: mode.iconName) - .font(.system(size: 14)) - .foregroundStyle(themeMode == mode ? RosettaColors.primaryBlue : RosettaColors.Adaptive.textSecondary) + @objc private func themeButtonTapped(_ sender: UIButton) { + let mode = ThemeMode.allCases[sender.tag] + themeModeRaw = mode.rawValue + updateThemeButtons() + applyThemeMode(mode) + } - Text(mode.label) - .font(.system(size: 15, weight: themeMode == mode ? .semibold : .regular)) - .foregroundStyle(themeMode == mode ? RosettaColors.primaryBlue : RosettaColors.Adaptive.text) - } - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) + private func updateThemeButtons() { + let current = themeMode + let blue = UIColor(RosettaColors.primaryBlue) + let secondary = UIColor(RosettaColors.Adaptive.textSecondary) + let text = UIColor(RosettaColors.Adaptive.text) + + for mode in ThemeMode.allCases { + guard let btn = themeButtons[mode] else { continue } + let isActive = mode == current + btn.tintColor = isActive ? blue : secondary + + var config = btn.configuration ?? .plain() + var titleAttr = AttributeContainer() + titleAttr.font = .systemFont(ofSize: 15, weight: isActive ? .semibold : .regular) + titleAttr.foregroundColor = isActive ? blue : text + config.attributedTitle = AttributedString(mode.label, attributes: titleAttr) + btn.configuration = config } - .buttonStyle(.plain) } private func applyThemeMode(_ mode: ThemeMode) { @@ -205,79 +433,313 @@ struct AppearanceView: View { // MARK: - Wallpaper Section - private var wallpaperSection: some View { - VStack(alignment: .leading, spacing: 10) { - Text("CHAT BACKGROUND") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - .padding(.horizontal, 4) + private func setupWallpaperSection() { + wallpaperSectionLabel.text = "CHAT BACKGROUND" + wallpaperSectionLabel.font = .systemFont(ofSize: 13, weight: .medium) + wallpaperSectionLabel.textColor = UIColor(RosettaColors.Adaptive.textSecondary) - LazyVGrid(columns: columns, spacing: 10) { - ForEach(WallpaperOption.allOptions) { option in - wallpaperCell(option) - } - } - } - } + let sectionStack = UIStackView() + sectionStack.axis = .vertical + sectionStack.spacing = 10 - private func wallpaperCell(_ option: WallpaperOption) -> some View { - let isSelected = selectedWallpaperId == option.id + let labelWrapper = UIView() + wallpaperSectionLabel.translatesAutoresizingMaskIntoConstraints = false + labelWrapper.addSubview(wallpaperSectionLabel) + NSLayoutConstraint.activate([ + wallpaperSectionLabel.leadingAnchor.constraint(equalTo: labelWrapper.leadingAnchor, constant: 4), + wallpaperSectionLabel.topAnchor.constraint(equalTo: labelWrapper.topAnchor), + wallpaperSectionLabel.bottomAnchor.constraint(equalTo: labelWrapper.bottomAnchor), + ]) + sectionStack.addArrangedSubview(labelWrapper) - return Button { - withAnimation(.easeInOut(duration: 0.2)) { - selectedWallpaperId = option.id - } - } label: { - ZStack { - wallpaperPreview(for: option.id) + // Grid: 3 columns + let options = WallpaperOption.allOptions + let columns = 3 + let rows = Int(ceil(Double(options.count) / Double(columns))) - // "None" label - if case .none = option.style { - Text("None") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - } + let gridStack = UIStackView() + gridStack.axis = .vertical + gridStack.spacing = 10 - // Selection checkmark - if isSelected { - VStack { - Spacer() - HStack { - Spacer() - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 22)) - .foregroundStyle(RosettaColors.primaryBlue) - .background(Circle().fill(.white).frame(width: 18, height: 18)) - .padding(8) - } + for row in 0.. some View { - let option = WallpaperOption.allOptions.first(where: { $0.id == wallpaperId }) + // MARK: - Update State + + private func updateSelection() { + let selected = selectedWallpaperId + + // Update wallpaper cells + for (id, cell) in wallpaperCells { + cell.setSelected(id == selected) + } + + // Update preview + let option = WallpaperOption.allOptions.first(where: { $0.id == selected }) ?? WallpaperOption.allOptions[0] - switch option.style { case .none: - RosettaColors.Adaptive.background - + previewWallpaperView.image = nil + previewContainer.backgroundColor = UIColor(RosettaColors.Adaptive.background) case .image(let assetName): - Image(assetName) - .resizable() - .aspectRatio(contentMode: .fill) + previewWallpaperView.image = UIImage(named: assetName) + previewContainer.backgroundColor = .clear + } + + // Update theme buttons + updateThemeButtons() + } + + // MARK: - Trait Changes + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle { + updateSelection() } } } + +// MARK: - UIGestureRecognizerDelegate + +extension AppearanceViewController: UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false } + let velocity = pan.velocity(in: pan.view) + return velocity.x > 0 && abs(velocity.x) > abs(velocity.y) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { false } +} + +// MARK: - Back Button (glass circle + chevron) + +private final class AppearanceBackButton: UIControl { + + private let glassView = TelegramGlassUIView(frame: .zero) + private let chevronLayer = CAShapeLayer() + private static let viewBox = CGSize(width: 10.7, height: 19.63) + + override init(frame: CGRect) { + super.init(frame: frame) + glassView.isUserInteractionEnabled = false + addSubview(glassView) + + chevronLayer.fillColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }.cgColor + layer.addSublayer(chevronLayer) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override var intrinsicContentSize: CGSize { CGSize(width: 44, height: 44) } + + override func layoutSubviews() { + super.layoutSubviews() + glassView.frame = bounds + glassView.fixedCornerRadius = bounds.height * 0.5 + glassView.updateGlass() + + guard bounds.width > 0 else { return } + let iconSize = CGSize(width: 11, height: 20) + let origin = CGPoint( + x: (bounds.width - iconSize.width) / 2, + y: (bounds.height - iconSize.height) / 2 + ) + var parser = SVGPathParser(pathData: TelegramIconPath.backChevron) + let rawPath = parser.parse() + let vb = Self.viewBox + var transform = CGAffineTransform(translationX: origin.x, y: origin.y) + .scaledBy(x: iconSize.width / vb.width, y: iconSize.height / vb.height) + chevronLayer.path = rawPath.copy(using: &transform) + chevronLayer.frame = bounds + } + + override var isHighlighted: Bool { + didSet { alpha = isHighlighted ? 0.6 : 1.0 } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle { + chevronLayer.fillColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }.cgColor + } + } +} + +// MARK: - Wallpaper Cell View + +private final class WallpaperCellView: UIView { + + var onTap: (() -> Void)? + + private let imageView = UIImageView() + private let noneLabel = UILabel() + private let checkmarkView = UIImageView() + private let borderLayer = CAShapeLayer() + private let option: WallpaperOption + + init(option: WallpaperOption) { + self.option = option + super.init(frame: .zero) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + private func setupUI() { + layer.cornerRadius = 14 + layer.cornerCurve = .continuous + clipsToBounds = true + + // Wallpaper image + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + switch option.style { + case .none: + imageView.image = nil + backgroundColor = UIColor(RosettaColors.Adaptive.background) + + noneLabel.text = "None" + noneLabel.font = .systemFont(ofSize: 13, weight: .medium) + noneLabel.textColor = UIColor(RosettaColors.Adaptive.textSecondary) + noneLabel.textAlignment = .center + noneLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(noneLabel) + NSLayoutConstraint.activate([ + noneLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + noneLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + case .image(let assetName): + imageView.image = UIImage(named: assetName) + } + + // Checkmark + let config = UIImage.SymbolConfiguration(pointSize: 22) + checkmarkView.image = UIImage(systemName: "checkmark.circle.fill", withConfiguration: config) + checkmarkView.tintColor = UIColor(RosettaColors.primaryBlue) + checkmarkView.isHidden = true + checkmarkView.translatesAutoresizingMaskIntoConstraints = false + + let checkBg = UIView() + checkBg.backgroundColor = .white + checkBg.layer.cornerRadius = 9 + checkBg.translatesAutoresizingMaskIntoConstraints = false + + addSubview(checkBg) + addSubview(checkmarkView) + NSLayoutConstraint.activate([ + checkmarkView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + checkmarkView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10), + checkmarkView.widthAnchor.constraint(equalToConstant: 22), + checkmarkView.heightAnchor.constraint(equalToConstant: 22), + + checkBg.centerXAnchor.constraint(equalTo: checkmarkView.centerXAnchor), + checkBg.centerYAnchor.constraint(equalTo: checkmarkView.centerYAnchor), + checkBg.widthAnchor.constraint(equalToConstant: 18), + checkBg.heightAnchor.constraint(equalToConstant: 18), + ]) + self.checkmarkBackground = checkBg + + // Border + borderLayer.fillColor = nil + borderLayer.lineWidth = 0.5 + borderLayer.strokeColor = UIColor.white.withAlphaComponent(0.1).cgColor + layer.addSublayer(borderLayer) + + // Tap + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + addGestureRecognizer(tap) + } + + private var checkmarkBackground: UIView! + + @objc private func handleTap() { + onTap?() + } + + func setSelected(_ selected: Bool) { + checkmarkView.isHidden = !selected + checkmarkBackground.isHidden = !selected + + let blue = UIColor(RosettaColors.primaryBlue) + borderLayer.strokeColor = selected + ? blue.cgColor + : UIColor.white.withAlphaComponent(0.1).cgColor + borderLayer.lineWidth = selected ? 2 : 0.5 + } + + override func layoutSubviews() { + super.layoutSubviews() + let path = UIBezierPath( + roundedRect: bounds.insetBy(dx: borderLayer.lineWidth / 2, dy: borderLayer.lineWidth / 2), + cornerRadius: 14 + ) + borderLayer.path = path.cgPath + borderLayer.frame = bounds + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle { + if case .image(let assetName) = option.style { + imageView.image = UIImage(named: assetName) + } + } + } +} + +// MARK: - SwiftUI Bridge (for legacy SettingsView navigation path) + +struct AppearanceViewRepresentable: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> AppearanceViewController { + AppearanceViewController() + } + func updateUIViewController(_ uiViewController: AppearanceViewController, context: Context) {} +} diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift index 74bd6b5..0ca8ff4 100644 --- a/Rosetta/Features/Settings/SettingsView.swift +++ b/Rosetta/Features/Settings/SettingsView.swift @@ -63,7 +63,7 @@ struct SettingsView: View { case .backup: BackupView() case .appearance: - AppearanceView() + AppearanceViewRepresentable() } } .task { diff --git a/Rosetta/Features/Settings/SettingsViewController.swift b/Rosetta/Features/Settings/SettingsViewController.swift index 15fa870..77ec24f 100644 --- a/Rosetta/Features/Settings/SettingsViewController.swift +++ b/Rosetta/Features/Settings/SettingsViewController.swift @@ -543,10 +543,9 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate { } @objc private func appearanceTapped() { - let hosting = UIHostingController(rootView: AppearanceView()) - hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + let vc = AppearanceViewController() onDetailStateChanged?(true) - navigationController?.pushViewController(hosting, animated: true) + navigationController?.pushViewController(vc, animated: true) } @objc private func updatesTapped() {