миграция экрана 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 titlePill: UIControl!
|
||||||
private var avatarButton: 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
|
// MARK: - Edge Effects
|
||||||
|
|
||||||
private let topEdgeEffectView = VariableBlurEdgeView(frame: .zero)
|
private let topEdgeEffectView = VariableBlurEdgeView(frame: .zero)
|
||||||
@@ -84,6 +93,7 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
||||||
|
|
||||||
|
setupWallpaper()
|
||||||
setupMessageListController()
|
setupMessageListController()
|
||||||
setupNavigationChrome()
|
setupNavigationChrome()
|
||||||
setupEdgeEffects()
|
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
|
// MARK: - Edge Effects
|
||||||
|
|
||||||
private func setupEdgeEffects() {
|
private func setupEdgeEffects() {
|
||||||
@@ -337,6 +385,7 @@ final class ChatDetailViewController: UIViewController {
|
|||||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||||
topEdgeEffectView.setTintColor(isDark ? .black : .white)
|
topEdgeEffectView.setTintColor(isDark ? .black : .white)
|
||||||
updateBottomGradientColors()
|
updateBottomGradientColors()
|
||||||
|
updateWallpaperImage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,9 +203,6 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateNavigationBlurHeight() {
|
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)
|
navigationBlurHeightConstraint?.constant = max(0, headerTotalHeight + 14.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,9 +219,10 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
editButtonControl.isUserInteractionEnabled = true
|
editButtonControl.isUserInteractionEnabled = true
|
||||||
rightButtonsControl.isUserInteractionEnabled = true
|
rightButtonsControl.isUserInteractionEnabled = true
|
||||||
toolbarTitleView.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
|
searchHeaderTopConstraint?.constant = headerBarHeight + searchTopSpacing
|
||||||
navigationBlurView.isHidden = false
|
navigationBlurView.isHidden = false
|
||||||
|
listController.view.isHidden = false
|
||||||
teardownSearchOverlayImmediately()
|
teardownSearchOverlayImmediately()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,9 +307,9 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
searchHeaderView.onActiveChanged = { [weak self] active in
|
searchHeaderView.onActiveChanged = { [weak self] active in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.applySearchExpansion(1.0, animated: true)
|
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.navigationBlurView.isHidden = active
|
||||||
|
self.listController.view.isHidden = active
|
||||||
|
self.updateNavigationBarBlur(progress: active ? 0.0 : (1.0 - self.lastSearchExpansion))
|
||||||
self.animateToolbarForSearch(active: active)
|
self.animateToolbarForSearch(active: active)
|
||||||
self.animateSearchBarPosition(active: active)
|
self.animateSearchBarPosition(active: active)
|
||||||
if active {
|
if active {
|
||||||
@@ -518,7 +516,7 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
view.insertSubview(bg, belowSubview: searchHeaderView)
|
view.insertSubview(bg, belowSubview: searchHeaderView)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
bg.topAnchor.constraint(equalTo: view.topAnchor),
|
bg.topAnchor.constraint(equalTo: searchHeaderView.bottomAnchor, constant: searchBottomSpacing),
|
||||||
bg.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
bg.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
bg.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
bg.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
bg.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
bg.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
@@ -534,25 +532,12 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|
|||||||
hosting.didMove(toParent: self)
|
hosting.didMove(toParent: self)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
// Content starts below search bar (not behind it)
|
hosting.view.topAnchor.constraint(equalTo: bg.topAnchor),
|
||||||
hosting.view.topAnchor.constraint(equalTo: searchHeaderView.bottomAnchor, constant: searchBottomSpacing),
|
|
||||||
hosting.view.leadingAnchor.constraint(equalTo: bg.leadingAnchor),
|
hosting.view.leadingAnchor.constraint(equalTo: bg.leadingAnchor),
|
||||||
hosting.view.trailingAnchor.constraint(equalTo: bg.trailingAnchor),
|
hosting.view.trailingAnchor.constraint(equalTo: bg.trailingAnchor),
|
||||||
hosting.view.bottomAnchor.constraint(equalTo: bg.bottomAnchor),
|
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
|
searchOverlayView = bg
|
||||||
searchContentHosting = hosting
|
searchContentHosting = hosting
|
||||||
|
|
||||||
@@ -1261,8 +1246,8 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
|||||||
|
|
||||||
// Telegram-style circular X button: 44pt, glass material
|
// Telegram-style circular X button: 44pt, glass material
|
||||||
cancelButton.translatesAutoresizingMaskIntoConstraints = false
|
cancelButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
let xConfig = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
|
// Telegram exact: custom drawn X — 2pt lineWidth, round cap, 40×40 canvas
|
||||||
cancelButton.setImage(UIImage(systemName: "xmark", withConfiguration: xConfig), for: .normal)
|
cancelButton.setImage(Self.telegramCloseIcon(size: 40, color: .white), for: .normal)
|
||||||
cancelButton.setTitle(nil, for: .normal)
|
cancelButton.setTitle(nil, for: .normal)
|
||||||
cancelButton.clipsToBounds = false
|
cancelButton.clipsToBounds = false
|
||||||
cancelButton.backgroundColor = .clear
|
cancelButton.backgroundColor = .clear
|
||||||
@@ -1408,6 +1393,24 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
|
|||||||
inlineClearButton.isUserInteractionEnabled = inlineClearButton.alpha > 0
|
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() {
|
@objc private func handleCapsuleTapped() {
|
||||||
guard !isSearchActive else { return }
|
guard !isSearchActive else { return }
|
||||||
setSearchActive(true, animated: true)
|
setSearchActive(true, animated: true)
|
||||||
@@ -1614,7 +1617,7 @@ private final class ChatListToolbarDualActionButton: UIView {
|
|||||||
private let backgroundView = ChatListToolbarGlassCapsuleView()
|
private let backgroundView = ChatListToolbarGlassCapsuleView()
|
||||||
private let addButton = UIButton(type: .system)
|
private let addButton = UIButton(type: .system)
|
||||||
private let composeButton = UIButton(type: .system)
|
private let composeButton = UIButton(type: .system)
|
||||||
private let dividerView = UIView()
|
// dividerView removed per Telegram parity
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
@@ -1626,18 +1629,18 @@ private final class ChatListToolbarDualActionButton: UIView {
|
|||||||
|
|
||||||
addButton.translatesAutoresizingMaskIntoConstraints = false
|
addButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
composeButton.translatesAutoresizingMaskIntoConstraints = false
|
composeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
dividerView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
addButton.tintColor = UIColor(RosettaColors.Adaptive.text)
|
addButton.tintColor = UIColor(RosettaColors.Adaptive.text)
|
||||||
composeButton.tintColor = UIColor(RosettaColors.Adaptive.text)
|
composeButton.tintColor = UIColor(RosettaColors.Adaptive.text)
|
||||||
addButton.accessibilityLabel = "Add"
|
addButton.accessibilityLabel = "Add"
|
||||||
composeButton.accessibilityLabel = "Compose"
|
composeButton.accessibilityLabel = "Compose"
|
||||||
|
|
||||||
let iconConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)
|
let iconConfig = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)
|
||||||
let addIcon = UIImage(named: "toolbar-add-chat")?.withRenderingMode(.alwaysTemplate)
|
let targetIconSize = CGSize(width: 19, height: 19)
|
||||||
?? UIImage(systemName: "plus", withConfiguration: iconConfig)
|
let addIcon = (UIImage(named: "toolbar-add-chat")?.withRenderingMode(.alwaysTemplate)
|
||||||
let composeIcon = UIImage(named: "toolbar-compose")?.withRenderingMode(.alwaysTemplate)
|
?? UIImage(systemName: "plus", withConfiguration: iconConfig))?.scaledTo(targetIconSize)
|
||||||
?? UIImage(systemName: "square.and.pencil", withConfiguration: iconConfig)
|
let composeIcon = (UIImage(named: "toolbar-compose")?.withRenderingMode(.alwaysTemplate)
|
||||||
|
?? UIImage(systemName: "square.and.pencil", withConfiguration: iconConfig))?.scaledTo(targetIconSize)
|
||||||
addButton.setImage(addIcon, for: .normal)
|
addButton.setImage(addIcon, for: .normal)
|
||||||
composeButton.setImage(composeIcon, 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])
|
addButton.addTarget(self, action: #selector(handleTouchUp(_:)), for: [.touchUpInside, .touchCancel, .touchDragExit])
|
||||||
composeButton.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(addButton)
|
||||||
addSubview(composeButton)
|
addSubview(composeButton)
|
||||||
addSubview(dividerView)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
backgroundView.topAnchor.constraint(equalTo: topAnchor),
|
backgroundView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
@@ -1663,20 +1663,15 @@ private final class ChatListToolbarDualActionButton: UIView {
|
|||||||
addButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
addButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
addButton.topAnchor.constraint(equalTo: topAnchor),
|
addButton.topAnchor.constraint(equalTo: topAnchor),
|
||||||
addButton.bottomAnchor.constraint(equalTo: bottomAnchor),
|
addButton.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
addButton.widthAnchor.constraint(equalToConstant: 44),
|
addButton.widthAnchor.constraint(equalToConstant: 36),
|
||||||
|
|
||||||
composeButton.trailingAnchor.constraint(equalTo: trailingAnchor),
|
composeButton.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
composeButton.topAnchor.constraint(equalTo: topAnchor),
|
composeButton.topAnchor.constraint(equalTo: topAnchor),
|
||||||
composeButton.bottomAnchor.constraint(equalTo: bottomAnchor),
|
composeButton.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
composeButton.widthAnchor.constraint(equalToConstant: 44),
|
composeButton.widthAnchor.constraint(equalToConstant: 36),
|
||||||
|
|
||||||
dividerView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
||||||
dividerView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
||||||
dividerView.widthAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale),
|
|
||||||
dividerView.heightAnchor.constraint(equalToConstant: 20),
|
|
||||||
|
|
||||||
heightAnchor.constraint(equalToConstant: 44),
|
heightAnchor.constraint(equalToConstant: 44),
|
||||||
widthAnchor.constraint(equalToConstant: 88),
|
widthAnchor.constraint(equalToConstant: 72),
|
||||||
])
|
])
|
||||||
|
|
||||||
self.frame = CGRect(origin: .zero, size: intrinsicContentSize)
|
self.frame = CGRect(origin: .zero, size: intrinsicContentSize)
|
||||||
@@ -1687,7 +1682,7 @@ private final class ChatListToolbarDualActionButton: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize {
|
override var intrinsicContentSize: CGSize {
|
||||||
CGSize(width: 88, height: 44)
|
CGSize(width: 72, height: 44)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleAddTapped() {
|
@objc private func handleAddTapped() {
|
||||||
@@ -1898,3 +1893,12 @@ private final class ChatListToolbarArcSpinnerView: UIView {
|
|||||||
layer.removeAnimation(forKey: animationKey)
|
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
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Wallpaper Definitions
|
// MARK: - Wallpaper Definitions
|
||||||
@@ -50,137 +51,364 @@ enum ThemeMode: String, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Appearance View
|
// MARK: - Appearance ViewController
|
||||||
|
|
||||||
struct AppearanceView: View {
|
final class AppearanceViewController: UIViewController {
|
||||||
@AppStorage("rosetta_wallpaper_id") private var selectedWallpaperId: String = "default"
|
|
||||||
@AppStorage("rosetta_theme_mode") private var themeModeRaw: String = "dark"
|
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 {
|
private var themeMode: ThemeMode {
|
||||||
ThemeMode(rawValue: themeModeRaw) ?? .dark
|
ThemeMode(rawValue: themeModeRaw) ?? .dark
|
||||||
}
|
}
|
||||||
|
|
||||||
private let columns = Array(repeating: GridItem(.flexible(), spacing: 10), count: 3)
|
// MARK: - Init
|
||||||
|
|
||||||
var body: some View {
|
init() {
|
||||||
ScrollView(showsIndicators: false) {
|
super.init(nibName: nil, bundle: nil)
|
||||||
VStack(spacing: 24) {
|
|
||||||
chatPreviewSection
|
|
||||||
themeSection
|
|
||||||
wallpaperSection
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 16)
|
@available(*, unavailable)
|
||||||
.padding(.bottom, 100)
|
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()
|
||||||
}
|
}
|
||||||
.background(RosettaColors.Adaptive.background)
|
|
||||||
.scrollContentBackground(.hidden)
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
super.viewWillAppear(animated)
|
||||||
.toolbar {
|
navigationController?.setNavigationBarHidden(true, animated: animated)
|
||||||
ToolbarItem(placement: .principal) {
|
}
|
||||||
Text("Appearance")
|
|
||||||
.font(.system(size: 17, weight: .semibold))
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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
|
// MARK: - Chat Preview
|
||||||
|
|
||||||
private var chatPreviewSection: some View {
|
private func setupChatPreview() {
|
||||||
VStack(spacing: 0) {
|
previewContainer.layer.cornerRadius = 20
|
||||||
ZStack {
|
previewContainer.layer.cornerCurve = .continuous
|
||||||
// Wallpaper background
|
previewContainer.clipsToBounds = true
|
||||||
wallpaperPreview(for: selectedWallpaperId)
|
|
||||||
.frame(height: 200)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
||||||
|
|
||||||
// Sample messages overlay
|
previewWallpaperView.contentMode = .scaleAspectFill
|
||||||
VStack(spacing: 6) {
|
previewWallpaperView.clipsToBounds = true
|
||||||
Spacer()
|
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
|
// Incoming bubble
|
||||||
HStack {
|
let inLabel = UILabel()
|
||||||
Text("Hey! How's it going?")
|
inLabel.text = "Hey! How's it going?"
|
||||||
.font(.system(size: 15))
|
inLabel.font = .systemFont(ofSize: 15)
|
||||||
.foregroundStyle(.white)
|
inLabel.textColor = .white
|
||||||
.padding(.horizontal, 14)
|
inLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
||||||
.fill(Color(hex: 0x2A2A2A))
|
|
||||||
)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outgoing message
|
incomingBubble.backgroundColor = UIColor(red: 42/255, green: 42/255, blue: 42/255, alpha: 1)
|
||||||
HStack {
|
incomingBubble.layer.cornerRadius = 18
|
||||||
Spacer()
|
incomingBubble.layer.cornerCurve = .continuous
|
||||||
Text("Great, thanks!")
|
incomingBubble.translatesAutoresizingMaskIntoConstraints = false
|
||||||
.font(.system(size: 15))
|
incomingBubble.addSubview(inLabel)
|
||||||
.foregroundStyle(.white)
|
NSLayoutConstraint.activate([
|
||||||
.padding(.horizontal, 14)
|
inLabel.topAnchor.constraint(equalTo: incomingBubble.topAnchor, constant: 8),
|
||||||
.padding(.vertical, 8)
|
inLabel.leadingAnchor.constraint(equalTo: incomingBubble.leadingAnchor, constant: 14),
|
||||||
.background(
|
inLabel.trailingAnchor.constraint(equalTo: incomingBubble.trailingAnchor, constant: -14),
|
||||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
inLabel.bottomAnchor.constraint(equalTo: incomingBubble.bottomAnchor, constant: -8),
|
||||||
.fill(RosettaColors.primaryBlue)
|
])
|
||||||
)
|
previewContainer.addSubview(incomingBubble)
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
// Outgoing bubble
|
||||||
.frame(height: 12)
|
let outLabel = UILabel()
|
||||||
}
|
outLabel.text = "Great, thanks!"
|
||||||
.padding(.horizontal, 12)
|
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
|
// MARK: - Theme Section
|
||||||
|
|
||||||
private var themeSection: some View {
|
private func setupThemeSection() {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
themeSectionLabel.text = "COLOR THEME"
|
||||||
Text("COLOR THEME")
|
themeSectionLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
.font(.system(size: 13, weight: .medium))
|
themeSectionLabel.textColor = UIColor(RosettaColors.Adaptive.textSecondary)
|
||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
|
|
||||||
SettingsCard {
|
let sectionStack = UIStackView()
|
||||||
HStack(spacing: 0) {
|
sectionStack.axis = .vertical
|
||||||
ForEach(ThemeMode.allCases, id: \.self) { mode in
|
sectionStack.spacing = 10
|
||||||
themeButton(mode)
|
|
||||||
if mode != ThemeMode.allCases.last {
|
let labelWrapper = UIView()
|
||||||
Divider()
|
themeSectionLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
.frame(height: 24)
|
labelWrapper.addSubview(themeSectionLabel)
|
||||||
.foregroundStyle(RosettaColors.Adaptive.divider)
|
NSLayoutConstraint.activate([
|
||||||
}
|
themeSectionLabel.leadingAnchor.constraint(equalTo: labelWrapper.leadingAnchor, constant: 4),
|
||||||
}
|
themeSectionLabel.topAnchor.constraint(equalTo: labelWrapper.topAnchor),
|
||||||
}
|
themeSectionLabel.bottomAnchor.constraint(equalTo: labelWrapper.bottomAnchor),
|
||||||
.frame(height: 52)
|
])
|
||||||
}
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func themeButton(_ mode: ThemeMode) -> some View {
|
// Dividers — added to themeCard directly (not the stack), positioned in layoutSubviews
|
||||||
Button {
|
for _ in 0..<2 {
|
||||||
withAnimation(.easeInOut(duration: 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func themeButtonTapped(_ sender: UIButton) {
|
||||||
|
let mode = ThemeMode.allCases[sender.tag]
|
||||||
themeModeRaw = mode.rawValue
|
themeModeRaw = mode.rawValue
|
||||||
}
|
updateThemeButtons()
|
||||||
applyThemeMode(mode)
|
applyThemeMode(mode)
|
||||||
} label: {
|
}
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: mode.iconName)
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundStyle(themeMode == mode ? RosettaColors.primaryBlue : RosettaColors.Adaptive.textSecondary)
|
|
||||||
|
|
||||||
Text(mode.label)
|
private func updateThemeButtons() {
|
||||||
.font(.system(size: 15, weight: themeMode == mode ? .semibold : .regular))
|
let current = themeMode
|
||||||
.foregroundStyle(themeMode == mode ? RosettaColors.primaryBlue : RosettaColors.Adaptive.text)
|
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
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyThemeMode(_ mode: ThemeMode) {
|
private func applyThemeMode(_ mode: ThemeMode) {
|
||||||
@@ -205,79 +433,313 @@ struct AppearanceView: View {
|
|||||||
|
|
||||||
// MARK: - Wallpaper Section
|
// MARK: - Wallpaper Section
|
||||||
|
|
||||||
private var wallpaperSection: some View {
|
private func setupWallpaperSection() {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
wallpaperSectionLabel.text = "CHAT BACKGROUND"
|
||||||
Text("CHAT BACKGROUND")
|
wallpaperSectionLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
.font(.system(size: 13, weight: .medium))
|
wallpaperSectionLabel.textColor = UIColor(RosettaColors.Adaptive.textSecondary)
|
||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
|
|
||||||
LazyVGrid(columns: columns, spacing: 10) {
|
let sectionStack = UIStackView()
|
||||||
ForEach(WallpaperOption.allOptions) { option in
|
sectionStack.axis = .vertical
|
||||||
wallpaperCell(option)
|
sectionStack.spacing = 10
|
||||||
}
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Grid: 3 columns
|
||||||
|
let options = WallpaperOption.allOptions
|
||||||
|
let columns = 3
|
||||||
|
let rows = Int(ceil(Double(options.count) / Double(columns)))
|
||||||
|
|
||||||
|
let gridStack = UIStackView()
|
||||||
|
gridStack.axis = .vertical
|
||||||
|
gridStack.spacing = 10
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func wallpaperCell(_ option: WallpaperOption) -> some View {
|
gridStack.addArrangedSubview(rowStack)
|
||||||
let isSelected = selectedWallpaperId == option.id
|
|
||||||
|
|
||||||
return Button {
|
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
|
||||||
selectedWallpaperId = option.id
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
ZStack {
|
|
||||||
wallpaperPreview(for: option.id)
|
|
||||||
|
|
||||||
// "None" label
|
|
||||||
if case .none = option.style {
|
|
||||||
Text("None")
|
|
||||||
.font(.system(size: 13, weight: .medium))
|
|
||||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selection checkmark
|
sectionStack.addArrangedSubview(gridStack)
|
||||||
if isSelected {
|
contentStack.addArrangedSubview(sectionStack)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Wallpaper Preview Renderer
|
private func selectWallpaper(_ id: String) {
|
||||||
|
selectedWallpaperId = id
|
||||||
|
updateSelection()
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
// MARK: - Update State
|
||||||
private func wallpaperPreview(for wallpaperId: String) -> some View {
|
|
||||||
let option = WallpaperOption.allOptions.first(where: { $0.id == wallpaperId })
|
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]
|
?? WallpaperOption.allOptions[0]
|
||||||
|
switch option.style {
|
||||||
|
case .none:
|
||||||
|
previewWallpaperView.image = nil
|
||||||
|
previewContainer.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
||||||
|
case .image(let assetName):
|
||||||
|
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 {
|
switch option.style {
|
||||||
case .none:
|
case .none:
|
||||||
RosettaColors.Adaptive.background
|
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):
|
case .image(let assetName):
|
||||||
Image(assetName)
|
imageView.image = UIImage(named: assetName)
|
||||||
.resizable()
|
}
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
|
// 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:
|
case .backup:
|
||||||
BackupView()
|
BackupView()
|
||||||
case .appearance:
|
case .appearance:
|
||||||
AppearanceView()
|
AppearanceViewRepresentable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
|
|||||||
@@ -543,10 +543,9 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func appearanceTapped() {
|
@objc private func appearanceTapped() {
|
||||||
let hosting = UIHostingController(rootView: AppearanceView())
|
let vc = AppearanceViewController()
|
||||||
hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
|
|
||||||
onDetailStateChanged?(true)
|
onDetailStateChanged?(true)
|
||||||
navigationController?.pushViewController(hosting, animated: true)
|
navigationController?.pushViewController(vc, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func updatesTapped() {
|
@objc private func updatesTapped() {
|
||||||
|
|||||||
Reference in New Issue
Block a user