import Foundation import SystemConfiguration import Components public extension UIColor { @objc(initWithHexInteger:alpha:) convenience init(_ hex: Int, alpha: CGFloat) { let r = CGFloat((hex & 0xFF0000) >> 16) / 255.0 let g = CGFloat((hex & 0xFF00) >> 8) / 255.0 let b = CGFloat(hex & 0xFF) / 255.0 self.init(red: r, green: g, blue: b, alpha: alpha) } @objc(initWithHexInteger:) convenience init(_ hex: Int) { self.init(hex, alpha: 1) } @objc class func wmf_colorWithHex(_ hex: Int) -> UIColor { return UIColor(hex) } // `initWithHexString:alpha:` should almost never be used. `initWithHexInteger:alpha:` is preferred. @objc(initWithHexString:alpha:) convenience init(_ hexString: String, alpha: CGFloat = 1.0) { let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) guard hex.count == 6, let int = Scanner(string: hex).scanInt32(representation: .hexadecimal), int != UINT32_MAX else { assertionFailure("Unexpected issue scanning hex string: \(hexString)") self.init(white: 0, alpha: alpha) return } self.init(Int(int), alpha: alpha) } // Make colors accessible to @objc @objc static var wmf_blue_700: UIColor { return .blue700 } @objc static var wmf_blue_300: UIColor { return .blue300 } @objc static var wmf_blue_600: UIColor { return .blue600 } @objc static var wmf_yellow_600: UIColor { return .yellow600 } @objc static var wmf_red_600: UIColor { return .red600 } @objc static var wmf_gray_400: UIColor { return .gray400 } @objc static var wmf_green_600: UIColor { return .green600 } @objc static var wmf_purple: UIColor { return .purple600 } @objc static var wmf_orange: UIColor { return .orange600 } @objc func wmf_hexStringIncludingAlpha(_ includeAlpha: Bool) -> String { var r: CGFloat = 0 var g: CGFloat = 0 var b: CGFloat = 0 var a: CGFloat = 0 getRed(&r, green: &g, blue: &b, alpha: &a) var hexString = String(format: "%02X%02X%02X", Int(255.0 * r), Int(255.0 * g), Int(255.0 * b)) if includeAlpha { hexString = hexString.appendingFormat("%02X", Int(255.0 * a)) } return hexString } @objc var wmf_hexString: String { return wmf_hexStringIncludingAlpha(false) } } @objc(WMFColors) public class Colors: NSObject { fileprivate static let light = Colors( identifier: .light) fileprivate static let sepia = Colors(identifier: .sepia) fileprivate static let dark = Colors(identifier: .dark) fileprivate static let black = Colors(identifier: .black) fileprivate static let widgetLight = Colors(identifier: .widgetLight) fileprivate static let widgetDark = Colors(identifier: .widgetDark) public let identifier: Identifier @objc public var baseBackground: UIColor { switch identifier { case .light, .widgetLight: return .gray200 case .sepia: return .beige400 case .dark, .black, .widgetDark: return .gray800 } } @objc public var midBackground: UIColor { switch identifier { case .light, .widgetLight: return .gray100 case .sepia: return .beige300 case .dark, .black, .widgetDark: return .gray700 } } @objc public var subCellBackground: UIColor { switch identifier { case .light: return .white case .sepia: return .beige300 case .dark, .black: return .gray700 case .widgetLight, .widgetDark: return .clear } } @objc public var paperBackground: UIColor { switch identifier { case .light, .widgetLight: return .white case .sepia: return .beige100 case .dark: return .gray675 case .black, .widgetDark: return .black } } @objc public var popoverBackground: UIColor { switch identifier { case .light, .sepia: return .white case .dark: return .gray800 case .black: return .gray700 case .widgetLight, .widgetDark: return .clear } } @objc public var chromeBackground: UIColor { switch identifier { case .light: return .white case .sepia: return .beige100 case .dark: return .gray700 case .black: return .gray700 case .widgetLight, .widgetDark: return .clear } } @objc public var chromeShadow: UIColor { switch identifier { case .light, .widgetLight: return .gray400 case .sepia: return .taupe200 case .dark, .widgetDark: return .gray650 case .black: return .gray675 } } @objc public var overlayBackground: UIColor { switch identifier { case .light, .widgetLight: return .black.withAlphaComponent(0.5) case .sepia: return .taupe600.withAlphaComponent(0.6) default: return .black.withAlphaComponent(0.75) } } @objc public var batchSelectionBackground: UIColor { switch identifier { case .light, .sepia, .widgetLight: return .blue100 case .dark, .black, .widgetDark: return .blue700 } } @objc public var referenceHighlightBackground: UIColor { switch identifier { case .light, .sepia, .dark, .widgetLight: return .clear case .black, .widgetDark: return .white.withAlphaComponent(0.2) } } @objc public var hintBackground: UIColor { switch identifier { case .light, .sepia, .widgetLight: return .blue100 case .dark, .widgetDark: return .gray800 case .black: return .gray650 } } @objc public var hintWarningText: UIColor { switch identifier { case .light, .sepia, .widgetLight: return .gray700 case .dark, .black, .widgetDark: return .yellow600 } } @objc public var hintWarningBackground: UIColor { switch identifier { case .light, .sepia: return .orange600 case .dark, .black: return .gray700 default: return .clear } } @objc public var animationBackground: UIColor { switch identifier { case .light, .sepia, .widgetLight: return .gray150 case .dark, .black, .widgetDark: return .gray700 } } @objc public var overlayText: UIColor { return .gray600 } @objc public var primaryText: UIColor { switch identifier { case .light, .sepia, .widgetLight: return .gray700 case .dark, .black, .widgetDark: return .gray100 } } @objc public var secondaryText: UIColor { switch identifier { case .light, .widgetLight: return .gray500 case .sepia: return .taupe600 default: return .gray300 } } @objc public var tertiaryText: UIColor { switch identifier { case .light: return .gray500 case .sepia: return .taupe600 default: return .gray300 } } @objc public var disabledText: UIColor { switch identifier { case .light: return .gray500 case .sepia: return .taupe600 default: return .gray300 } } @objc public var disabledLink: UIColor { switch identifier { case .light, .widgetLight: return .gray600 case .sepia: return .gray500 case .dark, .black, .widgetDark: return .gray400 } } @objc public var chromeText: UIColor { switch identifier { case .light, .sepia, .widgetLight: return .gray700 case .dark, .black, .widgetDark: return .gray100 } } @objc public var link: UIColor { switch identifier { case .light, .sepia, .widgetLight: return .blue600 case .dark, .black, .widgetDark: return .blue300 } } @objc public var accent: UIColor { switch identifier { default: return .green600 } } @objc public var secondaryAction: UIColor { return .blue700 } @objc public var destructive: UIColor { switch identifier { case .sepia: return .red700 default: return .red600 } } @objc public var warning: UIColor { switch identifier { case .light, .sepia, .widgetLight: return .orange600 case .dark, .black, .widgetDark: return .yellow600 } } @objc public var error: UIColor { switch identifier { case .sepia: return .red700 default: return .red600 } } @objc public var unselected: UIColor { switch identifier { case .light, .widgetLight: return .gray400 case .sepia: return .taupe600 case .dark, .black, .widgetDark: return .gray300 } } @objc public var border: UIColor { switch identifier { case .light, .widgetLight: return .gray400 case .sepia: return .taupe200 case .dark, .widgetDark: return .gray650 case .black: return .gray675 } } @objc public var shadow: UIColor { switch identifier { case .light, .widgetLight: return .gray200 case .sepia: return .taupe200 case .dark, .widgetDark: return .gray800 case .black: return .gray700 } } public var cardBackground: UIColor { switch identifier { case .light: return .white case .sepia: return .beige300 case .dark, .black: return .gray700 case .widgetLight, .widgetDark: return .clear } } public var selectedCardBackground: UIColor { switch identifier { case .light, .widgetLight: return .gray100 case .sepia: return .beige400 case .dark, .widgetDark: return .gray700 case .black: return .gray675 } } @objc public var cardBorder: UIColor { switch identifier { case .light, .widgetLight: return .gray100 case .sepia: return .taupe200 case .dark, .widgetDark: return .gray650 case .black: return .gray675 } } @objc public var cardShadow: UIColor { switch identifier { case .light: return .gray700 default: return .clear } } @objc public var cardButtonBackground: UIColor { switch identifier { case .light, .widgetLight: return .gray100 case .sepia: return .beige300 case .dark, .black, .widgetDark: return .gray650 } } @objc public var cardButtonSelectedBackground: UIColor { switch identifier { case .light, .widgetLight: return .gray100 case .sepia: return .taupe200 case .dark, .widgetDark: return .gray650 case .black: return .gray675 } } @objc public var icon: UIColor? { switch identifier { case .sepia: return .taupe600 case .dark, .black: return .gray300 default: return nil } } @objc public var iconBackground: UIColor? { switch identifier { case .sepia: return .beige400 case .dark, .black: return .gray675 default: return nil } } @objc public var searchFieldBackground: UIColor { return .darkSearchFieldBackground } @objc public var keyboardBarSearchFieldBackground: UIColor { switch identifier { case .light, .sepia, .widgetLight: return .gray200 case .dark, .black, .widgetDark: return .gray650 } } @objc public var rankGradientStart: UIColor { switch identifier { case .light, .sepia, .widgetLight: return .blue600 case .dark, .black, .widgetDark: return .blue300 } } @objc public var rankGradientEnd: UIColor { return .green600 } @objc public var rankGradient: Gradient { return Gradient(startColor: rankGradientStart, endColor: rankGradientEnd) } @objc public var blurEffectStyle: UIBlurEffect.Style { switch identifier { case .light, .sepia, .widgetLight: return .extraLight case .dark, .black, .widgetDark: return .dark } } @objc public var blurEffectBackground: UIColor { switch identifier { case .black, .dark, .widgetDark: return .gray300.withAlphaComponent(0.55) default: return .clear } } @objc public var tagText: UIColor { switch identifier { case .light: return .blue600 default: return .white } } @objc public var tagBackground: UIColor { switch identifier { case .light: return .blue600.withAlphaComponent(0.1) default: return .blue300 } } @objc public var tagSelectedBackground: UIColor { switch identifier { case .light: return .blue600.withAlphaComponent(0.25) default: return .blue600 } } @objc public var distanceBorder: UIColor { switch identifier { case .light, .widgetLight: return .gray400 case .sepia: return .taupe600 case .dark, .black, .widgetDark: return .gray300 } } @objc public var descriptionBackground: UIColor { switch identifier { case .light: return .yellow600 case .sepia, .widgetLight: return .orange600 case .dark, .black, .widgetDark: return .blue300 } } @objc public var descriptionWarning: UIColor { switch identifier { case .light, .sepia, .widgetLight: return .orange600 case .dark, .black, .widgetDark: return .yellow600 } } @objc public var pageIndicator: UIColor { return .blue100 } @objc public var pageIndicatorCurrent: UIColor { switch identifier { case .light, .sepia, .widgetLight: return .blue600 case .dark, .black, .widgetDark: return .blue300 } } @objc public var unreadIndicator: UIColor { return .green600 } @objc public var refreshControlTint: UIColor { return secondaryText } @objc public var inputAccessoryBackground: UIColor { switch identifier { case .light: return .white case .sepia: return .beige300 case .dark, .black: return .gray700 case .widgetLight, .widgetDark: return .clear } } @objc public var inputAccessoryButtonTint: UIColor { switch identifier { case .light, .sepia, .widgetLight: return .gray600 case .dark, .black, .widgetDark: return .gray100 } } @objc public var inputAccessoryButtonSelectedTint: UIColor { return primaryText } @objc public var inputAccessoryButtonSelectedBackgroundColor: UIColor { return baseBackground } public var diffTextAdd: UIColor { switch identifier { case .light: return .gray700 default: return .green600 } } public var diffTextDelete: UIColor { switch identifier { case .light: return .gray700 case .sepia: return.red700 case .dark, .black: return .red600 default: return .clear } } public var diffHighlightAdd: UIColor? { switch identifier { case .light: return .green100 default: return nil } } public var diffHighlightDelete: UIColor? { switch identifier { case .light: return .red100 default: return nil } } public var diffStrikethroughColor: UIColor { switch identifier { case .light: return .gray700 case .sepia: return .red700 case .dark, .black: return .red600 default: return .clear } } public var diffContextItemBackground: UIColor { switch identifier { case .light, .widgetLight: return .gray100 case .sepia: return .beige300 case .dark, .black, .widgetDark: return .gray700 } } public var diffContextItemBorder: UIColor { switch identifier { case .light, .widgetLight: return .gray400 case .sepia: return .taupe200 case .dark, .widgetDark: return .gray650 case .black: return .gray675 } } public var diffMoveParagraphBackground: UIColor { switch identifier { case .light, .widgetLight: return .gray100 case .sepia: return .beige300 case .dark, .black, .widgetDark: return .gray700 } } public var diffCompareAccent: UIColor { return .orange600.withAlphaComponent(0.3) } public var diffCompareChangeHeading: UIColor { switch identifier { case .light: return .white case .sepia: return .beige100 case .black, .dark: return .black default: return .clear } } public var talkPageCoffeRollBackground: UIColor { switch identifier { case .light: return .beige100 case .sepia: return .beige400 case .dark, .black: return .gray800 default: return .clear } } init(identifier: Identifier) { self.identifier = identifier } public enum Identifier { case light case sepia case dark case black case widgetLight case widgetDark } } @objc(WMFTheme) public class Theme: NSObject { @objc public static let standard = Theme.light @objc public let colors: Colors @objc public let isDark: Bool @objc public let hasInputAccessoryShadow: Bool @objc public var preferredStatusBarStyle: UIStatusBarStyle { return isDark ? .lightContent : .default } @objc public var scrollIndicatorStyle: UIScrollView.IndicatorStyle { return isDark ? .white : .black } @objc public var blurEffectStyle: UIBlurEffect.Style { return isDark ? .dark : .light } @objc public var keyboardAppearance: UIKeyboardAppearance { return isDark ? .dark : .light } @objc public lazy var navigationBarBackgroundImage: UIImage = { return UIImage.wmf_image(from: colors.paperBackground) }() @objc public lazy var sheetNavigationBarBackgroundImage: UIImage = { return UIImage.wmf_image(from: colors.chromeBackground) }() @objc public lazy var editorNavigationBarBackgroundImage: UIImage = { return UIImage.wmf_image(from: colors.inputAccessoryBackground) }() @objc public var navigationBarShadowImage: UIImage { return clearImage } @objc public lazy var clearImage: UIImage = { return #imageLiteral(resourceName: "transparent-pixel") }() static let tabBarItemBadgeParagraphStyle: NSParagraphStyle = { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.firstLineHeadIndent = 0.4 return paragraphStyle }() static let tabBarItemFont: UIFont = { return UIFont.systemFont(ofSize: 12) }() public lazy var tabBarItemBadgeTextAttributes: [NSAttributedString.Key: Any] = { return [NSAttributedString.Key.foregroundColor: colors.chromeBackground, NSAttributedString.Key.paragraphStyle: Theme.tabBarItemBadgeParagraphStyle] }() public lazy var tabBarTitleTextAttributes: [NSAttributedString.Key: Any] = { return [.foregroundColor: colors.secondaryText, .font: Theme.tabBarItemFont] }() public lazy var tabBarSelectedTitleTextAttributes: [NSAttributedString.Key: Any] = { return [.foregroundColor: colors.link, .font: Theme.tabBarItemFont] }() public static let exploreCardCornerRadius: CGFloat = 10 static func roundedRectImage(with color: UIColor, cornerRadius: CGFloat, width: CGFloat? = nil, height: CGFloat? = nil) -> UIImage? { let minDimension = 2 * cornerRadius + 1 let rect = CGRect(x: 0, y: 0, width: width ?? minDimension, height: height ?? minDimension) let scale = UIScreen.main.scale UIGraphicsBeginImageContextWithOptions(rect.size, false, scale) guard let context = UIGraphicsGetCurrentContext() else { return nil } context.setFillColor(color.cgColor) let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius) path.fill() let capInsets = UIEdgeInsets(top: cornerRadius, left: cornerRadius, bottom: cornerRadius, right: cornerRadius) let image = UIGraphicsGetImageFromCurrentImageContext()?.resizableImage(withCapInsets: capInsets) UIGraphicsEndImageContext() return image } @objc public lazy var searchFieldBackgroundImage: UIImage? = { return Theme.roundedRectImage(with: colors.searchFieldBackground, cornerRadius: 10, height: 36) }() @objc public lazy var navigationBarTitleTextAttributes: [NSAttributedString.Key: Any] = { return [NSAttributedString.Key.foregroundColor: colors.chromeText] }() public static let dimmedImageOpacity: CGFloat = 0.65 @objc public let imageOpacity: CGFloat @objc public let cardBorderWidthInPixels: Int @objc public let cardShadowOpacity: Float @objc public let name: String @objc public let displayName: String public let analyticsName: String public let webName: String @objc public let multiSelectIndicatorImage: UIImage? fileprivate static let lightMultiSelectIndicator = UIImage(named: "selected", in: Bundle.main, compatibleWith:nil) fileprivate static let darkMultiSelectIndicator = UIImage(named: "selected-dark", in: Bundle.main, compatibleWith:nil) private static let defaultCardBorderWidthInPixels: Int = 1 private static let lightCardBorderWidthInPixels: Int = { return DeviceInfo.shared.isOlderDevice ? 4 : defaultCardBorderWidthInPixels }() private static let defaultCardShadowOpacity: Float = { return DeviceInfo.shared.isOlderDevice ? 0 : 0.13 }() @objc public static let defaultThemeName = "standard" @objc public static let defaultAnalyticsThemeName = "default" private static let darkThemePrefix = "dark" private static let blackThemePrefix = "black" @objc public static func isDefaultThemeName(_ name: String?) -> Bool { guard let name = name else { return true } return name == defaultThemeName } @objc public static func isDarkThemeName(_ name: String?) -> Bool { guard let name = name else { return false } return name.hasPrefix(darkThemePrefix) || name.hasPrefix(blackThemePrefix) } @objc public static let light = Theme(colors: .light, imageOpacity: 1, cardBorderWidthInPixels: Theme.lightCardBorderWidthInPixels, cardShadowOpacity: defaultCardShadowOpacity, multiSelectIndicatorImage: Theme.lightMultiSelectIndicator, isDark: false, hasInputAccessoryShadow: true, name: "light", displayName: WMFLocalizedString("theme-light-display-name", value: "Light", comment: "Light theme name presented to the user"), analyticsName: "default", webName: "light") @objc public static let sepia = Theme(colors: .sepia, imageOpacity: 1, cardBorderWidthInPixels: Theme.defaultCardBorderWidthInPixels, cardShadowOpacity: 0, multiSelectIndicatorImage: Theme.lightMultiSelectIndicator, isDark: false, hasInputAccessoryShadow: false, name: "sepia", displayName: WMFLocalizedString("theme-sepia-display-name", value: "Sepia", comment: "Sepia theme name presented to the user"), analyticsName: "sepia", webName: "sepia") @objc public static let dark = Theme(colors: .dark, imageOpacity: 1, cardBorderWidthInPixels: Theme.defaultCardBorderWidthInPixels, cardShadowOpacity: 0, multiSelectIndicatorImage: Theme.darkMultiSelectIndicator, isDark: true, hasInputAccessoryShadow: false, name: darkThemePrefix, displayName: WMFLocalizedString("theme-dark-display-name", value: "Dark", comment: "Dark theme name presented to the user"), analyticsName: "dark", webName: "dark") @objc public static let darkDimmed = Theme(colors: .dark, imageOpacity: Theme.dimmedImageOpacity, cardBorderWidthInPixels: Theme.defaultCardBorderWidthInPixels, cardShadowOpacity: 0, multiSelectIndicatorImage: Theme.darkMultiSelectIndicator, isDark: true, hasInputAccessoryShadow: false, name: "\(darkThemePrefix)-dimmed", displayName: Theme.dark.displayName, analyticsName: "dark", webName: "dark") @objc public static let black = Theme(colors: .black, imageOpacity: 1, cardBorderWidthInPixels: Theme.defaultCardBorderWidthInPixels, cardShadowOpacity: 0, multiSelectIndicatorImage: Theme.darkMultiSelectIndicator, isDark: true, hasInputAccessoryShadow: false, name: blackThemePrefix, displayName: WMFLocalizedString("theme-black-display-name", value: "Black", comment: "Black theme name presented to the user"), analyticsName: "black", webName: "black") @objc public static let blackDimmed = Theme(colors: .black, imageOpacity: Theme.dimmedImageOpacity, cardBorderWidthInPixels: Theme.defaultCardBorderWidthInPixels, cardShadowOpacity: 0, multiSelectIndicatorImage: Theme.darkMultiSelectIndicator, isDark: true, hasInputAccessoryShadow: false, name: "\(blackThemePrefix)-dimmed", displayName: Theme.black.displayName, analyticsName: "black", webName: "black") @objc public static let widgetLight = Theme(colors: .widgetLight, imageOpacity: 1, cardBorderWidthInPixels: Theme.defaultCardBorderWidthInPixels, cardShadowOpacity: 0, multiSelectIndicatorImage: nil, isDark: false, hasInputAccessoryShadow: false, name: "widget-light", displayName: "", analyticsName: "", webName: "light") @objc public static let widgetDark = Theme(colors: .widgetDark, imageOpacity: 1, cardBorderWidthInPixels: Theme.defaultCardBorderWidthInPixels, cardShadowOpacity: 0, multiSelectIndicatorImage: nil, isDark: false, hasInputAccessoryShadow: false, name: "widget-dark", displayName: "", analyticsName: "", webName: "black") public class func widgetThemeCompatible(with traitCollection: UITraitCollection) -> Theme { return traitCollection.userInterfaceStyle == .dark ? Theme.widgetDark : Theme.widgetLight } init(colors: Colors, imageOpacity: CGFloat, cardBorderWidthInPixels: Int, cardShadowOpacity: Float, multiSelectIndicatorImage: UIImage?, isDark: Bool, hasInputAccessoryShadow: Bool, name: String, displayName: String, analyticsName: String, webName: String) { self.colors = colors self.imageOpacity = imageOpacity self.name = name self.displayName = displayName self.multiSelectIndicatorImage = multiSelectIndicatorImage self.isDark = isDark self.hasInputAccessoryShadow = hasInputAccessoryShadow self.cardBorderWidthInPixels = cardBorderWidthInPixels self.cardShadowOpacity = cardShadowOpacity self.analyticsName = analyticsName self.webName = webName } fileprivate static let themesByName = [Theme.light.name: Theme.light, Theme.dark.name: Theme.dark, Theme.sepia.name: Theme.sepia, Theme.darkDimmed.name: Theme.darkDimmed, Theme.black.name: Theme.black, Theme.blackDimmed.name: Theme.blackDimmed] @objc(withName:) public class func withName(_ name: String?) -> Theme? { guard let name = name else { return nil } return themesByName[name] } @objc public func withDimmingEnabled(_ isDimmingEnabled: Bool) -> Theme { guard let baseName = name.components(separatedBy: "-").first else { return self } let adjustedName = isDimmingEnabled ? "\(baseName)-dimmed" : baseName return Theme.withName(adjustedName) ?? self } } @objc(WMFThemeable) public protocol Themeable: AnyObject { @objc(applyTheme:) func apply(theme: Theme) // this might be better as a var theme: Theme { get set } - common VC superclasses could check for viewIfLoaded and call an update method in the setter. This would elminate the need for the viewIfLoaded logic in every applyTheme: } // Use for SwiftUI environment objects public final class ObservableTheme: ObservableObject { @Published public var theme: Theme public init(theme: Theme) { self.theme = theme } }