@ -1,125 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-40.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-60.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-29.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-58.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-87.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-80.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-120.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-121.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-180.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-20.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-41.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-30.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-59.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-42.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-81.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-76.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-152.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-167.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-1024.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
},
|
||||
"properties" : {
|
||||
"pre-rendered" : true
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 6.1 KiB |
Before Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 7.0 KiB |
Before Width: | Height: | Size: 739 B |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 31 KiB |
After Width: | Height: | Size: 117 KiB |
After Width: | Height: | Size: 167 KiB |
@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-Calculator-Preview.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-Calculator-Preview@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-Calculator-Preview@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 106 KiB |
After Width: | Height: | Size: 177 KiB |
@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-Meeting-Preview.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-Meeting-Preview@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-Meeting-Preview@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 27 KiB |
After Width: | Height: | Size: 97 KiB |
After Width: | Height: | Size: 144 KiB |
@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-News-Preview.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-News-Preview@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-News-Preview@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 98 KiB |
After Width: | Height: | Size: 131 KiB |
@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-Notes-Preview.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-Notes-Preview@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-Notes-Preview@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 21 KiB |
After Width: | Height: | Size: 9.3 KiB |
@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-Stocks-Preview.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-Stocks-Preview@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-Stocks-Preview@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 126 KiB |
After Width: | Height: | Size: 147 KiB |
@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-Weather-Preview.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-Weather-Preview@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-Weather-Preview@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 424 KiB |
@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 664 KiB |
@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 516 KiB |
@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 303 KiB |
@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 20 KiB |
@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 359 KiB |
@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -0,0 +1,172 @@
|
||||
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
// MARK: - AppIcon
|
||||
|
||||
enum AppIcon: String, CaseIterable {
|
||||
case session = "AppIcon"
|
||||
|
||||
case weather = "AppIcon-Weather"
|
||||
case stocks = "AppIcon-Stocks"
|
||||
case news = "AppIcon-News"
|
||||
case notes = "AppIcon-Notes"
|
||||
case meetings = "AppIcon-Meeting"
|
||||
case calculator = "AppIcon-Calculator"
|
||||
|
||||
/// Annoyingly the alternate icons don't seem to be renderable directly so we need to include
|
||||
/// additional copies in order to render in the UI
|
||||
var previewImageName: String { "\(rawValue)-Preview" }
|
||||
|
||||
// stringlint:ignore_contents
|
||||
init(name: String?) {
|
||||
switch name {
|
||||
case "AppIcon-Weather": self = .weather
|
||||
case "AppIcon-Stocks": self = .stocks
|
||||
case "AppIcon-News": self = .news
|
||||
case "AppIcon-Notes": self = .notes
|
||||
case "AppIcon-Meeting": self = .meetings
|
||||
case "AppIcon-Calculator": self = .calculator
|
||||
default: self = .session
|
||||
}
|
||||
}
|
||||
|
||||
init?(rawValue: String) {
|
||||
self.init(name: rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AppIconViewModel
|
||||
|
||||
class AppIconViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource {
|
||||
public let dependencies: Dependencies
|
||||
public let navigatableState: NavigatableState = NavigatableState()
|
||||
public let state: TableDataState<Section, TableItem> = TableDataState()
|
||||
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
|
||||
private let selectedOptionsSubject: CurrentValueSubject<String?, Never>
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(using dependencies: Dependencies) {
|
||||
self.dependencies = dependencies
|
||||
|
||||
/// Retrieve the current icon name
|
||||
var currentIconName: String?
|
||||
|
||||
switch Thread.isMainThread {
|
||||
case true: currentIconName = UIApplication.shared.alternateIconName
|
||||
case false:
|
||||
DispatchQueue.main.sync {
|
||||
currentIconName = UIApplication.shared.alternateIconName
|
||||
}
|
||||
}
|
||||
|
||||
selectedOptionsSubject = CurrentValueSubject(currentIconName)
|
||||
}
|
||||
|
||||
// MARK: - Section
|
||||
|
||||
public enum Section: SessionTableSection {
|
||||
case appIcon
|
||||
case icon
|
||||
|
||||
var title: String? {
|
||||
switch self {
|
||||
case .appIcon: return "appIcon".localized()
|
||||
case .icon: return "appIconSelectionTitle".localized()
|
||||
}
|
||||
}
|
||||
|
||||
var style: SessionTableSectionStyle {
|
||||
switch self {
|
||||
case .appIcon: return .titleRoundedContent
|
||||
case .icon: return .padding
|
||||
}
|
||||
}
|
||||
|
||||
var footer: String? {
|
||||
switch self {
|
||||
case .icon:
|
||||
return "appIconDescription"
|
||||
.put(key: "app_name", value: Constants.app_name)
|
||||
.localized()
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum TableItem: Equatable, Hashable, Differentiable {
|
||||
case appIconUseAlternate
|
||||
case iconGrid
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
private struct State: Equatable {
|
||||
let alternateAppIconName: String?
|
||||
}
|
||||
|
||||
let title: String = "sessionAppearance".localized()
|
||||
|
||||
lazy var observation: TargetObservation = ObservationBuilder
|
||||
.subject(selectedOptionsSubject)
|
||||
.mapWithPrevious { [weak self, dependencies] previous, current -> [SectionModel] in
|
||||
return [
|
||||
SectionModel(
|
||||
model: .appIcon,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .appIconUseAlternate,
|
||||
title: SessionCell.TextInfo(
|
||||
"appIconEnableIcon".localized(),
|
||||
font: .titleRegular
|
||||
),
|
||||
trailingAccessory: .toggle(
|
||||
(current != nil),
|
||||
oldValue: (previous != nil)
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
switch current {
|
||||
case .some: self?.updateAppIcon(nil)
|
||||
case .none: self?.updateAppIcon(.weather)
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
),
|
||||
SectionModel(
|
||||
model: .icon,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .iconGrid,
|
||||
leadingAccessory: .custom(
|
||||
info: AppIconGridView.Info(
|
||||
selectedIcon: AppIcon(name: current),
|
||||
onChange: { icon in self?.updateAppIcon(icon) }
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private func updateAppIcon(_ icon: AppIcon?) {
|
||||
// Ignore if there wasn't a change
|
||||
guard selectedOptionsSubject.value != icon?.rawValue else { return }
|
||||
|
||||
UIApplication.shared.setAlternateIconName(icon?.rawValue) { error in
|
||||
guard let error: Error = error else { return }
|
||||
|
||||
Log.error("Failed to set alternate icon: \(error)")
|
||||
}
|
||||
|
||||
selectedOptionsSubject.send(icon?.rawValue)
|
||||
}
|
||||
}
|
@ -1,300 +0,0 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class AppearanceViewController: BaseVC {
|
||||
// MARK: - Initialization
|
||||
|
||||
private let dependencies: Dependencies
|
||||
|
||||
init(using dependencies: Dependencies) {
|
||||
self.dependencies = dependencies
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
private let scrollView: UIScrollView = {
|
||||
let result: UIScrollView = UIScrollView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.showsHorizontalScrollIndicator = false
|
||||
result.contentInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
leading: 0,
|
||||
bottom: Values.largeSpacing,
|
||||
trailing: 0
|
||||
)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let contentView: UIView = UIView()
|
||||
|
||||
private let themesTitleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = UIFont.systemFont(ofSize: Values.mediumFontSize, weight: .regular)
|
||||
result.themeTextColor = .textSecondary
|
||||
result.text = "appearanceThemes".localized()
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let themesStackView: UIStackView = {
|
||||
let result: UIStackView = UIStackView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = true
|
||||
result.axis = .vertical
|
||||
result.distribution = .equalCentering
|
||||
result.alignment = .fill
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var themeSelectionViews: [ThemeSelectionView] = Theme.allCases
|
||||
.map { theme in
|
||||
let result: ThemeSelectionView = ThemeSelectionView(theme: theme) { [weak self] theme in
|
||||
ThemeManager.updateThemeState(theme: theme)
|
||||
}
|
||||
result.update(isSelected: (ThemeManager.currentTheme == theme))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private let primaryColorTitleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = UIFont.systemFont(ofSize: Values.mediumFontSize, weight: .regular)
|
||||
result.themeTextColor = .textSecondary
|
||||
result.text = "appearancePrimaryColor".localized()
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let primaryColorPreviewStackView: UIStackView = {
|
||||
let result: UIStackView = UIStackView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.axis = .vertical
|
||||
result.distribution = .equalCentering
|
||||
result.alignment = .fill
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var primaryColorPreviewView: ThemePreviewView = {
|
||||
let result: ThemePreviewView = ThemePreviewView(using: dependencies)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let primaryColorScrollView: UIScrollView = {
|
||||
let result: UIScrollView = UIScrollView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.showsHorizontalScrollIndicator = false
|
||||
result.contentInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
leading: Values.largeSpacing,
|
||||
bottom: 0,
|
||||
trailing: Values.largeSpacing
|
||||
)
|
||||
|
||||
if Dependencies.isRTL {
|
||||
result.transform = CGAffineTransform.identity.scaledBy(x: -1, y: 1)
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let primaryColorSelectionStackView: UIStackView = {
|
||||
let result: UIStackView = UIStackView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.axis = .horizontal
|
||||
result.distribution = .equalCentering
|
||||
result.alignment = .fill
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var primaryColorSelectionViews: [PrimaryColorSelectionView] = Theme.PrimaryColor.allCases
|
||||
.map { color in
|
||||
let result: PrimaryColorSelectionView = PrimaryColorSelectionView(color: color) { [weak self] color in
|
||||
ThemeManager.updateThemeState(primaryColor: color)
|
||||
}
|
||||
result.update(isSelected: (ThemeManager.primaryColor == color))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private let nightModeTitleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = UIFont.systemFont(ofSize: Values.mediumFontSize, weight: .regular)
|
||||
result.themeTextColor = .textSecondary
|
||||
result.text = "appearanceAutoDarkMode".localized()
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let nightModeStackView: UIStackView = {
|
||||
let result: UIStackView = UIStackView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.axis = .vertical
|
||||
result.distribution = .equalCentering
|
||||
result.alignment = .fill
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let nightModeToggleView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.themeBackgroundColor = .appearance_sectionBackground
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let nightModeToggleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = UIFont.systemFont(ofSize: Values.mediumFontSize, weight: .regular)
|
||||
result.themeTextColor = .textPrimary
|
||||
result.text = "followSystemSettings".localized()
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var nightModeToggleSwitch: UISwitch = {
|
||||
let result: UISwitch = UISwitch()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.themeOnTintColor = .primary
|
||||
result.isOn = ThemeManager.matchSystemNightModeSetting
|
||||
result.addTarget(self, action: #selector(nightModeToggleChanged(sender:)), for: .valueChanged)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
ViewControllerUtilities.setUpDefaultSessionStyle(
|
||||
for: self,
|
||||
title: "sessionAppearance".localized(),
|
||||
hasCustomBackButton: false
|
||||
)
|
||||
|
||||
view.themeBackgroundColor = .backgroundPrimary
|
||||
view.addSubview(scrollView)
|
||||
|
||||
// Note: Need to add to a 'contentView' to ensure the automatic RTL behaviour
|
||||
// works properly (apparently it doesn't play nicely with UIScrollView internals)
|
||||
scrollView.addSubview(contentView)
|
||||
|
||||
contentView.addSubview(themesTitleLabel)
|
||||
contentView.addSubview(themesStackView)
|
||||
contentView.addSubview(primaryColorTitleLabel)
|
||||
contentView.addSubview(primaryColorPreviewStackView)
|
||||
contentView.addSubview(primaryColorScrollView)
|
||||
contentView.addSubview(nightModeTitleLabel)
|
||||
contentView.addSubview(nightModeStackView)
|
||||
|
||||
themesStackView.addArrangedSubview(UIView.separator())
|
||||
themeSelectionViews.forEach { view in
|
||||
themesStackView.addArrangedSubview(view)
|
||||
themesStackView.addArrangedSubview(UIView.separator())
|
||||
}
|
||||
|
||||
primaryColorPreviewStackView.addArrangedSubview(UIView.separator())
|
||||
primaryColorPreviewStackView.addArrangedSubview(primaryColorPreviewView)
|
||||
primaryColorPreviewStackView.addArrangedSubview(UIView.separator())
|
||||
|
||||
primaryColorScrollView.addSubview(primaryColorSelectionStackView)
|
||||
|
||||
primaryColorSelectionViews.forEach { view in
|
||||
primaryColorSelectionStackView.addArrangedSubview(view)
|
||||
}
|
||||
|
||||
nightModeStackView.addArrangedSubview(UIView.separator())
|
||||
nightModeStackView.addArrangedSubview(nightModeToggleView)
|
||||
nightModeStackView.addArrangedSubview(UIView.separator())
|
||||
|
||||
nightModeToggleView.addSubview(nightModeToggleLabel)
|
||||
nightModeToggleView.addSubview(nightModeToggleSwitch)
|
||||
|
||||
// Register an observer so when the theme changes the selected theme and primary color
|
||||
// are both updated to match
|
||||
ThemeManager.onThemeChange(observer: self) { [weak self] theme, primaryColor in
|
||||
self?.themeSelectionViews.forEach { view in
|
||||
view.update(isSelected: (theme == view.theme))
|
||||
}
|
||||
|
||||
self?.primaryColorSelectionViews.forEach { view in
|
||||
view.update(isSelected: (primaryColor == view.color))
|
||||
}
|
||||
}
|
||||
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
private func setupLayout() {
|
||||
scrollView.pin(to: view)
|
||||
contentView.pin(to: scrollView)
|
||||
contentView.set(.width, to: .width, of: scrollView)
|
||||
|
||||
themesTitleLabel.pin(.top, to: .top, of: contentView)
|
||||
themesTitleLabel.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
|
||||
themesStackView.pin(.top, to: .bottom, of: themesTitleLabel, withInset: Values.mediumSpacing)
|
||||
themesStackView.pin(.leading, to: .leading, of: contentView)
|
||||
themesStackView.set(.width, to: .width, of: contentView)
|
||||
|
||||
primaryColorTitleLabel.pin(.top, to: .bottom, of: themesStackView, withInset: Values.mediumSpacing)
|
||||
primaryColorTitleLabel.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
|
||||
primaryColorPreviewStackView.pin(.top, to: .bottom, of: primaryColorTitleLabel, withInset: Values.smallSpacing)
|
||||
primaryColorPreviewStackView.pin(.leading, to: .leading, of: contentView)
|
||||
primaryColorPreviewStackView.set(.width, to: .width, of: contentView)
|
||||
|
||||
primaryColorScrollView.pin(.top, to: .bottom, of: primaryColorPreviewStackView, withInset: Values.mediumSpacing)
|
||||
primaryColorScrollView.pin(.leading, to: .leading, of: contentView)
|
||||
primaryColorScrollView.set(.width, to: .width, of: contentView)
|
||||
|
||||
primaryColorSelectionStackView.pin(to: primaryColorScrollView)
|
||||
primaryColorSelectionStackView.set(.height, to: .height, of: primaryColorScrollView)
|
||||
|
||||
nightModeTitleLabel.pin(.top, to: .bottom, of: primaryColorScrollView, withInset: Values.largeSpacing)
|
||||
nightModeTitleLabel.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
nightModeTitleLabel.set(.width, to: .width, of: contentView, withOffset: -(Values.largeSpacing * 2))
|
||||
|
||||
nightModeStackView.pin(.top, to: .bottom, of: nightModeTitleLabel, withInset: Values.smallSpacing)
|
||||
nightModeStackView.pin(.bottom, to: .bottom, of: contentView)
|
||||
nightModeStackView.pin(.leading, to: .leading, of: contentView)
|
||||
nightModeStackView.set(.width, to: .width, of: contentView)
|
||||
|
||||
nightModeToggleLabel.setContentHugging(.vertical, to: .required)
|
||||
nightModeToggleLabel.setCompressionResistance(.vertical, to: .required)
|
||||
nightModeToggleLabel.center(.vertical, in: nightModeToggleView)
|
||||
nightModeToggleLabel.pin(.leading, to: .leading, of: nightModeToggleView, withInset: Values.largeSpacing)
|
||||
|
||||
nightModeToggleSwitch.pin(.top, to: .top, of: nightModeToggleView, withInset: Values.smallSpacing)
|
||||
nightModeToggleSwitch.pin(.bottom, to: .bottom, of: nightModeToggleView, withInset: -Values.smallSpacing)
|
||||
nightModeToggleSwitch.pin(.trailing, to: .trailing, of: nightModeToggleView, withInset: -Values.largeSpacing)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func nightModeToggleChanged(sender: UISwitch) {
|
||||
ThemeManager.updateThemeState(matchSystemNightModeSetting: sender.isOn)
|
||||
}
|
||||
}
|
@ -0,0 +1,168 @@
|
||||
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
class AppearanceViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource {
|
||||
public let dependencies: Dependencies
|
||||
public let navigatableState: NavigatableState = NavigatableState()
|
||||
public let state: TableDataState<Section, TableItem> = TableDataState()
|
||||
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(using dependencies: Dependencies) {
|
||||
self.dependencies = dependencies
|
||||
}
|
||||
|
||||
// MARK: - Section
|
||||
|
||||
public enum Section: SessionTableSection {
|
||||
case themes
|
||||
case primaryColor
|
||||
case primaryColorSelection
|
||||
case autoDarkMode
|
||||
case appIcon
|
||||
|
||||
var title: String? {
|
||||
switch self {
|
||||
case .themes: return "appearanceThemes".localized()
|
||||
case .primaryColor: return "appearancePrimaryColor".localized()
|
||||
case .primaryColorSelection: return nil
|
||||
case .autoDarkMode: return "appearanceAutoDarkMode".localized()
|
||||
case .appIcon: return "appIcon".localized()
|
||||
}
|
||||
}
|
||||
|
||||
var style: SessionTableSectionStyle {
|
||||
switch self {
|
||||
case .primaryColorSelection: return .none
|
||||
default: return .titleRoundedContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum TableItem: Equatable, Hashable, Differentiable {
|
||||
case theme(String)
|
||||
case primaryColorPreview
|
||||
case primaryColorSelectionView
|
||||
case darkModeMatchSystemSettings
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
private struct State: Equatable {
|
||||
let theme: Theme
|
||||
let primaryColor: Theme.PrimaryColor
|
||||
let authDarkModeEnabled: Bool
|
||||
}
|
||||
|
||||
let title: String = "sessionAppearance".localized()
|
||||
|
||||
lazy var observation: TargetObservation = ObservationBuilder
|
||||
.databaseObservation(self) { db -> State in
|
||||
State(
|
||||
theme: db[.theme].defaulting(to: .classicDark),
|
||||
primaryColor: db[.themePrimaryColor].defaulting(to: .green),
|
||||
authDarkModeEnabled: db[.themeMatchSystemDayNightCycle]
|
||||
)
|
||||
}
|
||||
.map { [weak self, dependencies] state -> [SectionModel] in
|
||||
return [
|
||||
SectionModel(
|
||||
model: .themes,
|
||||
elements: Theme.allCases.map { theme in
|
||||
SessionCell.Info(
|
||||
id: .theme(theme.rawValue),
|
||||
leadingAccessory: .custom(
|
||||
info: ThemePreviewView.Info(theme: theme)
|
||||
),
|
||||
title: theme.title,
|
||||
trailingAccessory: .radio(
|
||||
isSelected: (state.theme == theme)
|
||||
),
|
||||
onTap: {
|
||||
ThemeManager.updateThemeState(theme: theme)
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
SectionModel(
|
||||
model: .primaryColor,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .primaryColorPreview,
|
||||
leadingAccessory: .custom(
|
||||
info: ThemeMessagePreviewView.Info()
|
||||
)
|
||||
)
|
||||
]
|
||||
),
|
||||
SectionModel(
|
||||
model: .primaryColorSelection,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .primaryColorSelectionView,
|
||||
leadingAccessory: .custom(
|
||||
info: PrimaryColorSelectionView.Info(
|
||||
primaryColor: state.primaryColor,
|
||||
onChange: { color in
|
||||
ThemeManager.updateThemeState(primaryColor: color)
|
||||
}
|
||||
)
|
||||
),
|
||||
styling: SessionCell.StyleInfo(
|
||||
customPadding: .none,
|
||||
backgroundStyle: .noBackground
|
||||
)
|
||||
)
|
||||
]
|
||||
),
|
||||
SectionModel(
|
||||
model: .autoDarkMode,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .darkModeMatchSystemSettings,
|
||||
title: SessionCell.TextInfo(
|
||||
"followSystemSettings".localized(),
|
||||
font: .titleRegular
|
||||
),
|
||||
trailingAccessory: .toggle(
|
||||
state.authDarkModeEnabled,
|
||||
oldValue: ThemeManager.matchSystemNightModeSetting
|
||||
),
|
||||
onTap: {
|
||||
ThemeManager.updateThemeState(
|
||||
matchSystemNightModeSetting: !state.authDarkModeEnabled
|
||||
)
|
||||
}
|
||||
)
|
||||
]
|
||||
),
|
||||
SectionModel(
|
||||
model: .appIcon,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .darkModeMatchSystemSettings,
|
||||
title: SessionCell.TextInfo(
|
||||
"appIconSelect".localized(),
|
||||
font: .titleRegular
|
||||
),
|
||||
trailingAccessory: .icon(.chevronRight),
|
||||
onTap: { [weak self, dependencies] in
|
||||
self?.transitionToScreen(
|
||||
SessionTableViewController(
|
||||
viewModel: AppIconViewModel(using: dependencies)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
@ -0,0 +1,280 @@
|
||||
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class AppIconGridView: UIView {
|
||||
public static let size: SessionCell.Accessory.Size = .fillWidthWrapHeight
|
||||
|
||||
/// Excluding the default icon
|
||||
private var icons: [AppIcon] = AppIcon.allCases.filter { $0 != .session }
|
||||
private var onChange: ((AppIcon) -> ())?
|
||||
private let maxContentWidth: CGFloat
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
lazy var contentViewViewHeightConstraint: NSLayoutConstraint = contentView.heightAnchor
|
||||
.constraint(equalToConstant: IconView.expectedMinSize)
|
||||
private var iconViewTopConstraints: [NSLayoutConstraint] = []
|
||||
private var iconViewLeadingConstraints: [NSLayoutConstraint] = []
|
||||
private var iconViewWidthConstraints: [NSLayoutConstraint] = []
|
||||
|
||||
private let contentView: UIView = UIView()
|
||||
|
||||
private lazy var iconViews: [IconView] = icons.map { icon in
|
||||
IconView(icon: icon) { [weak self] in self?.onChange?(icon) }
|
||||
}
|
||||
|
||||
// MARK: - Initializtion
|
||||
|
||||
init(maxContentWidth: CGFloat) {
|
||||
self.maxContentWidth = maxContentWidth
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
setupUI()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Use init(theme:) instead")
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func setupUI() {
|
||||
addSubview(contentView)
|
||||
|
||||
iconViews.forEach { contentView.addSubview($0) }
|
||||
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
private func setupLayout() {
|
||||
contentView.pin(to: self)
|
||||
|
||||
iconViews.forEach {
|
||||
iconViewTopConstraints.append($0.pin(.top, to: .top, of: contentView))
|
||||
iconViewLeadingConstraints.append($0.pin(.leading, to: .leading, of: contentView))
|
||||
iconViewWidthConstraints.append($0.set(.width, to: IconView.minImageSize))
|
||||
}
|
||||
}
|
||||
|
||||
/// We want the icons to fill the available space in either a 6x1 grid or a 3x2 grid depending on the available width so
|
||||
/// we need to calculate the `targetSize` and `targetSpacing` for the `IconView`
|
||||
private func calculatedSizes(for availableWidth: CGFloat) -> (size: CGFloat, spacing: CGFloat) {
|
||||
let acceptedIconsPerColumn: [CGFloat] = [CGFloat(iconViews.count), 3]
|
||||
let minSpacing: CGFloat = Values.smallSpacing
|
||||
|
||||
for iconsPerColumn in acceptedIconsPerColumn {
|
||||
let minTotalSpacing: CGFloat = ((iconsPerColumn - 1) * minSpacing)
|
||||
let availableWidthLessSpacing: CGFloat = (availableWidth - minTotalSpacing)
|
||||
let size: CGFloat = floor(availableWidthLessSpacing / iconsPerColumn)
|
||||
let spacing: CGFloat = ((availableWidth - (size * iconsPerColumn)) / (iconsPerColumn - 1))
|
||||
|
||||
/// If all of the icons would fit and be larger than the expected min size then that's the size we want to use
|
||||
if size >= IconView.expectedMinSize {
|
||||
return (size, spacing)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback to the min sizes to prevent a future change resulting in a `0` value
|
||||
return (IconView.expectedMinSize, minSpacing)
|
||||
}
|
||||
|
||||
private func calculateIconViewFrames() -> [CGRect] {
|
||||
let (targetSize, targetSpacing): (CGFloat, CGFloat) = calculatedSizes(for: maxContentWidth)
|
||||
var nextX: CGFloat = 0
|
||||
var nextY: CGFloat = 0
|
||||
|
||||
/// We calculate the size based on the position for the next `IconView` so we will end up with an extra `Values.smallSpacing`
|
||||
/// on both dimensions which needs to be removed
|
||||
return iconViews.enumerated().reduce(into: []) { result, next in
|
||||
/// First add the calculated position/size for this element
|
||||
result.append(
|
||||
CGRect(
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
width: targetSize,
|
||||
height: targetSize
|
||||
)
|
||||
)
|
||||
|
||||
/// We are at the last element so no need to calculate additional frames
|
||||
guard next.offset < iconViews.count - 1 else { return }
|
||||
|
||||
/// Calculate the position the next `IconView` should have
|
||||
nextX += (targetSize + targetSpacing)
|
||||
|
||||
/// If the end of the next icon would go past the `maxContentWidth` then wrap to the next line
|
||||
if nextX + targetSize > maxContentWidth {
|
||||
nextX = 0
|
||||
nextY += (targetSize + targetSpacing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
return calculateIconViewFrames().reduce(.zero) { result, next -> CGSize in
|
||||
CGSize(width: max(result.width, next.maxX), height: max(result.height, next.maxY))
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
/// Only bother laying out if we haven't already done so
|
||||
guard
|
||||
!iconViewTopConstraints.contains(where: { $0.constant > 0 }) ||
|
||||
!iconViewLeadingConstraints.contains(where: { $0.constant > 0 })
|
||||
else { return }
|
||||
|
||||
/// We manually layout the `IconView` instances because it's easier than trying to get a good "overflow" behaviour doing it
|
||||
/// manually than using existing UI elements
|
||||
let frames: [CGRect] = calculateIconViewFrames()
|
||||
|
||||
/// Sanity check to avoid an index out of bounds
|
||||
guard
|
||||
iconViews.count == frames.count &&
|
||||
iconViews.count == iconViewTopConstraints.count &&
|
||||
iconViews.count == iconViewLeadingConstraints.count &&
|
||||
iconViews.count == iconViewWidthConstraints.count
|
||||
else { return }
|
||||
|
||||
iconViews.enumerated().forEach { index, iconView in
|
||||
iconViewTopConstraints[index].constant = frames[index].minY
|
||||
iconViewLeadingConstraints[index].constant = frames[index].minX
|
||||
iconViewWidthConstraints[index].constant = frames[index].width
|
||||
|
||||
UIView.performWithoutAnimation { iconView.layoutIfNeeded() }
|
||||
}
|
||||
|
||||
contentViewViewHeightConstraint.constant = frames
|
||||
.reduce(0) { result, next -> CGFloat in max(result, next.maxY) }
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
fileprivate func update(with selectedIcon: AppIcon?, onChange: @escaping (AppIcon) -> ()) {
|
||||
self.onChange = onChange
|
||||
|
||||
iconViews.enumerated().forEach { index, iconView in
|
||||
iconView.update(isSelected: (icons[index] == selectedIcon))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Info
|
||||
|
||||
extension AppIconGridView: SessionCell.Accessory.CustomView {
|
||||
struct Info: Equatable, SessionCell.Accessory.CustomViewInfo {
|
||||
typealias View = AppIconGridView
|
||||
|
||||
let selectedIcon: AppIcon?
|
||||
let onChange: (AppIcon) -> ()
|
||||
|
||||
static func == (lhs: Info, rhs: Info) -> Bool {
|
||||
return (lhs.selectedIcon == rhs.selectedIcon)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
selectedIcon.hash(into: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> AppIconGridView {
|
||||
return AppIconGridView(maxContentWidth: maxContentWidth)
|
||||
}
|
||||
|
||||
func update(with info: Info) {
|
||||
update(with: info.selectedIcon, onChange: info.onChange)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IconView
|
||||
|
||||
extension AppIconGridView {
|
||||
class IconView: UIView {
|
||||
fileprivate static let minImageSize: CGFloat = 85
|
||||
fileprivate static let selectionInset: CGFloat = 4
|
||||
fileprivate static var expectedMinSize: CGFloat = (minImageSize + (selectionInset * 2))
|
||||
|
||||
private let onSelected: () -> ()
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var backgroundButton: UIButton = UIButton(
|
||||
type: .custom,
|
||||
primaryAction: UIAction(handler: { [weak self] _ in
|
||||
self?.onSelected()
|
||||
})
|
||||
)
|
||||
|
||||
private let selectionBorderView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.themeBorderColor = .radioButton_selectedBorder
|
||||
result.layer.borderWidth = 2
|
||||
result.layer.cornerRadius = 21
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let imageView: UIImageView = {
|
||||
let result: UIImageView = UIImageView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.contentMode = .scaleAspectFit
|
||||
result.layer.cornerRadius = 16
|
||||
result.clipsToBounds = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Initializtion
|
||||
|
||||
init(icon: AppIcon, onSelected: @escaping () -> ()) {
|
||||
self.onSelected = onSelected
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
setupUI(icon: icon)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Use init(color:) instead")
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func setupUI(icon: AppIcon) {
|
||||
imageView.image = UIImage(named: icon.previewImageName)
|
||||
|
||||
addSubview(backgroundButton)
|
||||
addSubview(selectionBorderView)
|
||||
addSubview(imageView)
|
||||
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
private func setupLayout() {
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
backgroundButton.pin(to: self)
|
||||
|
||||
selectionBorderView.pin(to: self)
|
||||
|
||||
imageView.pin(to: selectionBorderView, withInset: IconView.selectionInset)
|
||||
imageView.set(.height, to: .width, of: imageView)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
func update(isSelected: Bool) {
|
||||
selectionBorderView.isHidden = !isSelected
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,115 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class ThemeMessagePreviewView: UIView {
|
||||
public static let size: SessionCell.Accessory.Size = .fillWidthWrapHeight
|
||||
|
||||
private let dependencies: Dependencies
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var incomingMessagePreview: UIView = {
|
||||
let result: VisibleMessageCell = VisibleMessageCell()
|
||||
result.translatesAutoresizingMaskIntoConstraints = true
|
||||
result.update(
|
||||
with: MessageViewModel(
|
||||
variant: .standardIncoming,
|
||||
body: "appearancePreview2".localized(),
|
||||
quote: Quote(
|
||||
interactionId: -1,
|
||||
authorId: "",
|
||||
timestampMs: 0,
|
||||
body: "appearancePreview1".localized(),
|
||||
attachmentId: nil
|
||||
),
|
||||
cellType: .textOnlyMessage
|
||||
),
|
||||
mediaCache: NSCache(),
|
||||
playbackInfo: nil,
|
||||
showExpandedReactions: false,
|
||||
lastSearchText: nil,
|
||||
using: dependencies
|
||||
)
|
||||
|
||||
// Remove built-in padding
|
||||
result.authorLabelTopConstraint.constant = 0
|
||||
result.contentViewLeadingConstraint1.constant = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var outgoingMessagePreview: UIView = {
|
||||
let result: VisibleMessageCell = VisibleMessageCell()
|
||||
result.translatesAutoresizingMaskIntoConstraints = true
|
||||
result.update(
|
||||
with: MessageViewModel(
|
||||
variant: .standardOutgoing,
|
||||
body: "appearancePreview3".localized(),
|
||||
cellType: .textOnlyMessage,
|
||||
isLast: false // To hide the status indicator
|
||||
),
|
||||
mediaCache: NSCache(),
|
||||
playbackInfo: nil,
|
||||
showExpandedReactions: false,
|
||||
lastSearchText: nil,
|
||||
using: dependencies
|
||||
)
|
||||
|
||||
// Remove built-in padding
|
||||
result.authorLabelTopConstraint.constant = 0
|
||||
result.contentViewTrailingConstraint1.constant = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Initializtion
|
||||
|
||||
init(using dependencies: Dependencies) {
|
||||
self.dependencies = dependencies
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
setupUI()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func setupUI() {
|
||||
addSubview(incomingMessagePreview)
|
||||
addSubview(outgoingMessagePreview)
|
||||
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
private func setupLayout() {
|
||||
incomingMessagePreview.pin(.top, to: .top, of: self)
|
||||
incomingMessagePreview.pin(.leading, to: .leading, of: self)
|
||||
|
||||
outgoingMessagePreview.pin(.top, to: .bottom, of: incomingMessagePreview, withInset: Values.mediumSpacing)
|
||||
outgoingMessagePreview.pin(.trailing, to: .trailing, of: self)
|
||||
outgoingMessagePreview.pin(.bottom, to: .bottom, of: self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Info
|
||||
|
||||
extension ThemeMessagePreviewView: SessionCell.Accessory.CustomView {
|
||||
struct Info: Equatable, SessionCell.Accessory.CustomViewInfo {
|
||||
typealias View = ThemeMessagePreviewView
|
||||
}
|
||||
|
||||
static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> ThemeMessagePreviewView {
|
||||
return ThemeMessagePreviewView(using: dependencies)
|
||||
}
|
||||
|
||||
// No need to do anything (theme with auto-update)
|
||||
func update(with info: Info) {}
|
||||
}
|
@ -1,92 +1,97 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public class ThemePreviewView: UIView {
|
||||
private let dependencies: Dependencies
|
||||
final class ThemePreviewView: UIView {
|
||||
public static let size: SessionCell.Accessory.Size = .fixed(width: 76, height: 70)
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var incomingMessagePreview: UIView = {
|
||||
let result: VisibleMessageCell = VisibleMessageCell()
|
||||
result.translatesAutoresizingMaskIntoConstraints = true
|
||||
result.update(
|
||||
with: MessageViewModel(
|
||||
variant: .standardIncoming,
|
||||
body: "appearancePreview2".localized(),
|
||||
quote: Quote(
|
||||
interactionId: -1,
|
||||
authorId: "",
|
||||
timestampMs: 0,
|
||||
body: "appearancePreview1".localized(),
|
||||
attachmentId: nil
|
||||
),
|
||||
cellType: .textOnlyMessage
|
||||
),
|
||||
mediaCache: NSCache(),
|
||||
playbackInfo: nil,
|
||||
showExpandedReactions: false,
|
||||
lastSearchText: nil,
|
||||
using: dependencies
|
||||
)
|
||||
private let previewIncomingMessageView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.layer.cornerRadius = 6
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var outgoingMessagePreview: UIView = {
|
||||
let result: VisibleMessageCell = VisibleMessageCell()
|
||||
result.translatesAutoresizingMaskIntoConstraints = true
|
||||
result.update(
|
||||
with: MessageViewModel(
|
||||
variant: .standardOutgoing,
|
||||
body: "appearancePreview3".localized(),
|
||||
cellType: .textOnlyMessage,
|
||||
isLast: false // To hide the status indicator
|
||||
),
|
||||
mediaCache: NSCache(),
|
||||
playbackInfo: nil,
|
||||
showExpandedReactions: false,
|
||||
lastSearchText: nil,
|
||||
using: dependencies
|
||||
)
|
||||
private let previewOutgoingMessageView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.layer.cornerRadius = 6
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Initializtion
|
||||
|
||||
init(using dependencies: Dependencies) {
|
||||
self.dependencies = dependencies
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
setupUI()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
fatalError("Use init(theme:) instead")
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func setupUI() {
|
||||
self.themeBackgroundColor = .appearance_sectionBackground
|
||||
isUserInteractionEnabled = false
|
||||
layer.cornerRadius = 6
|
||||
layer.borderWidth = 1
|
||||
|
||||
addSubview(incomingMessagePreview)
|
||||
addSubview(outgoingMessagePreview)
|
||||
// Add the UI
|
||||
addSubview(previewIncomingMessageView)
|
||||
addSubview(previewOutgoingMessageView)
|
||||
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
private func setupLayout() {
|
||||
incomingMessagePreview.pin(.top, to: .top, of: self)
|
||||
incomingMessagePreview.pin(.leading, to: .leading, of: self, withInset: Values.veryLargeSpacing)
|
||||
previewIncomingMessageView.pin(.bottom, toCenterOf: self, withInset: -1)
|
||||
previewIncomingMessageView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing)
|
||||
previewIncomingMessageView.set(.width, to: 40)
|
||||
previewIncomingMessageView.set(.height, to: 12)
|
||||
|
||||
previewOutgoingMessageView.pin(.top, toCenterOf: self, withInset: 1)
|
||||
previewOutgoingMessageView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing)
|
||||
previewOutgoingMessageView.set(.width, to: 40)
|
||||
previewOutgoingMessageView.set(.height, to: 12)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
fileprivate func update(with theme: Theme) {
|
||||
themeBackgroundColorForced = .theme(theme, color: .backgroundPrimary)
|
||||
themeBorderColorForced = .theme(theme, color: .borderSeparator)
|
||||
|
||||
// Set the appropriate colours
|
||||
previewIncomingMessageView.themeBackgroundColorForced = .theme(theme, color: .messageBubble_incomingBackground)
|
||||
previewOutgoingMessageView.themeBackgroundColorForced = .theme(theme, color: .defaultPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Info
|
||||
|
||||
extension ThemePreviewView: SessionCell.Accessory.CustomView {
|
||||
struct Info: Equatable, SessionCell.Accessory.CustomViewInfo {
|
||||
typealias View = ThemePreviewView
|
||||
|
||||
outgoingMessagePreview.pin(.top, to: .bottom, of: incomingMessagePreview)
|
||||
outgoingMessagePreview.pin(.trailing, to: .trailing, of: self, withInset: -Values.veryLargeSpacing)
|
||||
outgoingMessagePreview.pin(.bottom, to: .bottom, of: self, withInset: -Values.mediumSpacing)
|
||||
let theme: Theme
|
||||
}
|
||||
|
||||
static func create(maxContentWidth: CGFloat, using dependencies: Dependencies) -> ThemePreviewView {
|
||||
return ThemePreviewView()
|
||||
}
|
||||
|
||||
func update(with info: Info) {
|
||||
update(with: info.theme)
|
||||
}
|
||||
}
|
||||
|