миграция экрана Appearance на UIKit + обновление обоев из Android
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 2.0 MiB |
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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..<rows {
|
||||
let rowStack = UIStackView()
|
||||
rowStack.axis = .horizontal
|
||||
rowStack.spacing = 10
|
||||
rowStack.distribution = .fillEqually
|
||||
|
||||
for col in 0..<columns {
|
||||
let idx = row * columns + col
|
||||
if idx < options.count {
|
||||
let option = options[idx]
|
||||
let cell = WallpaperCellView(option: option)
|
||||
cell.onTap = { [weak self] in
|
||||
self?.selectWallpaper(option.id)
|
||||
}
|
||||
cell.heightAnchor.constraint(equalToConstant: 140).isActive = true
|
||||
rowStack.addArrangedSubview(cell)
|
||||
wallpaperCells[option.id] = cell
|
||||
} else {
|
||||
let spacer = UIView()
|
||||
rowStack.addArrangedSubview(spacer)
|
||||
}
|
||||
}
|
||||
.frame(height: 140)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(isSelected ? RosettaColors.primaryBlue : Color.white.opacity(0.1), lineWidth: isSelected ? 2 : 0.5)
|
||||
)
|
||||
|
||||
gridStack.addArrangedSubview(rowStack)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
sectionStack.addArrangedSubview(gridStack)
|
||||
contentStack.addArrangedSubview(sectionStack)
|
||||
}
|
||||
|
||||
// MARK: - Wallpaper Preview Renderer
|
||||
private func selectWallpaper(_ id: String) {
|
||||
selectedWallpaperId = id
|
||||
updateSelection()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func wallpaperPreview(for wallpaperId: String) -> 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) {}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ struct SettingsView: View {
|
||||
case .backup:
|
||||
BackupView()
|
||||
case .appearance:
|
||||
AppearanceView()
|
||||
AppearanceViewRepresentable()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user