diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 86f717f8c..b411209ab 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -35,7 +35,7 @@ public struct SignInView: View { VStack { Button(action: { viewModel.router.back() }, label: { CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .backButtonStyle(color: .white) + .backButtonStyle(color: Theme.Colors.loginNavigationText) }) .foregroundColor(Theme.Colors.styledButtonText) .padding(.leading, isHorizontal ? 48 : 0) @@ -141,7 +141,7 @@ public struct SignInView: View { } .padding(.horizontal, 24) .padding(.top, 50) - }.roundedBackground(Theme.Colors.background) + }.roundedBackground(Theme.Colors.loginBackground) .scrollAvoidKeyboard(dismissKeyboardByTap: true) } diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 17986ec7e..97e1053cd 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -39,12 +39,12 @@ public struct SignUpView: View { ZStack { HStack { Text(CoreLocalization.SignIn.registerBtn) - .titleSettings(color: Theme.Colors.white) + .titleSettings(color: Theme.Colors.loginNavigationText) } VStack { Button(action: { viewModel.router.back() }, label: { CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .backButtonStyle(color: Theme.Colors.white) + .backButtonStyle(color: Theme.Colors.loginNavigationText) }) .foregroundColor(Theme.Colors.styledButtonText) .padding(.leading, isHorizontal ? 48 : 0) diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index 84703fa5d..cb91ac413 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -34,8 +34,8 @@ public struct ResetPasswordView: View { VStack(alignment: .center) { NavigationBar(title: AuthLocalization.Forgot.title, - titleColor: Theme.Colors.white, - leftButtonColor: Theme.Colors.white, + titleColor: Theme.Colors.loginNavigationText, + leftButtonColor: Theme.Colors.loginNavigationText, leftButtonAction: { viewModel.router.back() }).padding(.leading, isHorizontal ? 48 : 0) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index c3289cc3d..d7552335e 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -118,6 +118,7 @@ 0770DE5F28D0B22C006D8A5D /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE5E28D0B22C006D8A5D /* Strings.swift */; }; 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE6028D0B2CB006D8A5D /* Assets.swift */; }; 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */; }; + A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30427D2B20B299009B64B7 /* SocialAuthError.swift */; }; BA76135C2B21BC7300B599B7 /* SocialAuthResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA76135B2B21BC7300B599B7 /* SocialAuthResponse.swift */; }; BA8B3A2F2AD546A700D25EF5 /* DebugLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8B3A2E2AD546A700D25EF5 /* DebugLog.swift */; }; @@ -291,6 +292,7 @@ 3B74C6685E416657F3C5F5A8 /* Pods-App-Core.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.releaseprod.xcconfig"; sourceTree = ""; }; 60153262DBC2F9E660D7E11B /* Pods-App-Core.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.release.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.release.xcconfig"; sourceTree = ""; }; 9D5B06CAA99EA5CD49CBE2BB /* Pods-App-Core.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugdev.xcconfig"; sourceTree = ""; }; + A53A32342B233DEC005FE38A /* ThemeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeConfig.swift; sourceTree = ""; }; BA30427D2B20B299009B64B7 /* SocialAuthError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; BA76135B2B21BC7300B599B7 /* SocialAuthResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthResponse.swift; sourceTree = ""; }; BA8B3A2E2AD546A700D25EF5 /* DebugLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugLog.swift; sourceTree = ""; }; @@ -707,6 +709,7 @@ BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */, BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */, BAFB99912B14E23D007D09F9 /* AppleSignInConfig.swift */, + A53A32342B233DEC005FE38A /* ThemeConfig.swift */, ); path = Config; sourceTree = ""; @@ -1040,6 +1043,7 @@ 024BE3DF29B2615500BCDEE2 /* CGColorExtension.swift in Sources */, 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */, 0727878928D31734002E9142 /* User.swift in Sources */, + A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */, 02280F5B294B4E6F0032823A /* Connectivity.swift in Sources */, 02066B482906F73400F4307E /* PickerMenu.swift in Sources */, ); diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index 75c6dc7c4..7cd10c224 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -22,6 +22,7 @@ public protocol ConfigProtocol { var google: GoogleConfig { get } var appleSignIn: AppleSignInConfig { get } var features: FeaturesConfig { get } + var theme: ThemeConfig { get } var uiComponents: UIComponentsConfig { get } } diff --git a/Core/Core/Configuration/Config/ThemeConfig.swift b/Core/Core/Configuration/Config/ThemeConfig.swift new file mode 100644 index 000000000..239cd69fe --- /dev/null +++ b/Core/Core/Configuration/Config/ThemeConfig.swift @@ -0,0 +1,28 @@ +// +// ThemeConfig.swift +// Core +// +// Created by Anton Yarmolenka on 01/12/2023. +// + +import Foundation + +private enum ThemeKeys: String { + case isRoundedCorners = "ROUNDED_CORNERS_STYLE" +} + +public final class ThemeConfig: NSObject { + public var isRoundedCorners: Bool + + init(dictionary: [String: AnyObject]) { + isRoundedCorners = dictionary[ThemeKeys.isRoundedCorners.rawValue] as? Bool != false + super.init() + } +} + +private let ThemeKey = "THEME" +extension Config { + public var theme: ThemeConfig { + ThemeConfig(dictionary: self[ThemeKey] as? [String: AnyObject] ?? [:]) + } +} diff --git a/Core/Core/View/Base/ProgressBar.swift b/Core/Core/View/Base/ProgressBar.swift index d3ddb9b45..cf1c12802 100644 --- a/Core/Core/View/Base/ProgressBar.swift +++ b/Core/Core/View/Base/ProgressBar.swift @@ -39,7 +39,7 @@ public struct ProgressBar: View { ZStack { Circle() .stroke(lineWidth: lineWidth) - .foregroundColor(Color.blue.opacity(0.3)) + .foregroundColor(Theme.Colors.accentColor.opacity(0.3)) .frame(width: size, height: size) Circle() diff --git a/Core/Core/View/Base/SocialAuthButton.swift b/Core/Core/View/Base/SocialAuthButton.swift index 71ea25dbc..3ebd367e7 100644 --- a/Core/Core/View/Base/SocialAuthButton.swift +++ b/Core/Core/View/Base/SocialAuthButton.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Theme public struct SocialAuthButton: View { @@ -54,7 +55,7 @@ public struct SocialAuthButton: View { .frame(maxWidth: idiom == .pad ? 260: .infinity, minHeight: 42) .background(backgroundColor) .clipShape( - RoundedRectangle(cornerRadius: cornerRadius) + Theme.Shapes.buttonShape ) } diff --git a/Core/Core/View/Base/StyledButton.swift b/Core/Core/View/Base/StyledButton.swift index d7dea0da2..7190c2469 100644 --- a/Core/Core/View/Base/StyledButton.swift +++ b/Core/Core/View/Base/StyledButton.swift @@ -22,7 +22,7 @@ public struct StyledButton: View { public init(_ title: String, action: @escaping () -> Void, isTransparent: Bool = false, - color: Color = Theme.Colors.accentColor, + color: Color = Theme.Colors.accentButtonColor, textColor: Color = Theme.Colors.styledButtonText, disabledTextColor: Color = Theme.Colors.textPrimary, borderColor: Color = .clear, @@ -58,9 +58,10 @@ public struct StyledButton: View { .fill(isTransparent ? .clear : buttonColor) ) .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) - .foregroundColor(isTransparent ? Theme.Colors.white : borderColor) + Theme.Shapes.buttonShape + .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) + .foregroundColor(isTransparent ? Theme.Colors.white : borderColor) + ) .accessibilityElement(children: .ignore) .accessibilityLabel(title) diff --git a/Core/Core/View/Base/UnitButtonView.swift b/Core/Core/View/Base/UnitButtonView.swift index 3f50e3279..99f1f34d6 100644 --- a/Core/Core/View/Base/UnitButtonView.swift +++ b/Core/Core/View/Base/UnitButtonView.swift @@ -143,22 +143,22 @@ public struct UnitButtonView: View { Theme.Shapes.buttonShape .fill(type == .previous ? Theme.Colors.background - : Theme.Colors.accentColor) + : Theme.Colors.accentButtonColor) .shadow(color: Color.black.opacity(0.25), radius: 21, y: 4) .overlay( - RoundedRectangle(cornerRadius: 8) + Theme.Shapes.buttonShape .stroke(style: .init( lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1) ) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.accentButtonColor) ) case .continueLesson, .nextSection, .reload, .finish, .custom: Theme.Shapes.buttonShape - .fill(bgColor ?? Theme.Colors.accentColor) + .fill(bgColor ?? Theme.Colors.accentButtonColor) .shadow(color: (type == .first || type == .next @@ -168,14 +168,14 @@ public struct UnitButtonView: View { || type == .reload) ? Color.black.opacity(0.25) : .clear, radius: 21, y: 4) .overlay( - RoundedRectangle(cornerRadius: 8) + Theme.Shapes.buttonShape .stroke(style: .init( lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1 )) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.accentButtonColor) ) } } diff --git a/Documentation/Theming_implementation.md b/Documentation/Theming_implementation.md new file mode 100644 index 000000000..7d8ba6f61 --- /dev/null +++ b/Documentation/Theming_implementation.md @@ -0,0 +1,80 @@ +# Theming Implementation +This documentation provides instructions on how to implement Theme assets for the OpenEdX iOS project. + +## Python dependecies +The `whitelabel.py` theming script requires the following Python dependencies to be installed: +- `pip3 install coloredlogs` +- `pip3 install pillow` +- `pip3 install pyyaml` + +## How to Run the Script +The theming script `whitelabel.py` can be run from the OpenEdX iOS root project folder with the following command: +```bash +python3 config_script/whitelabel.py --config-file=path/to/configfile/whitelabel.yaml -v +``` +where +- `config_script/whitelabel.py` is the path to the `whitelabel.py` script +- `--config-file=path/to/configfile/whitelabel.yaml` is the path to the configuration file `whitelabel.yaml` +- `-v` sets the log level (all messages if '-v' is present and errors only if is not). + +## Example of whitelabel.yaml +You can get example of `whitelabel.yaml` file by run next command: +```bash +python3 config_script/whitelabel.py --help-config-file +``` +Just copy script's output to your `whitelabel.yaml` file. + +## Config Options +The config file `whitelabel.yaml` can be created by yourself or obtained from some config repo. +This config can contain the following options: +### Folder with source assets +This is the folder where all image assets, which should be copied into the project, are placed (can be relative or absolute): +```yaml +import_dir: 'path/to/images/source' +``` +### Xcode Project Settings +The theming script can change the app name, version, development team and app bundle ID: +```yaml +project_config: + project_path: 'path/to/project/project.pbxproj' # path to project.pbxproj file + dev_team: '1234567890' # Apple development team ID + marketing_version: '1.0.1' # App marketing version + current_project_version: '2' # App build number + configurations: + config1: # Build Configuration name in project + app_bundle_id: "bundle.id.app.new1" # Bundle ID to be set + product_name: "Mobile App Name1" # App Name to be set + config2: # Build Configuration name in project + app_bundle_id: "bundle.id.app.new2" # Bundle ID to be set + product_name: "Mobile App Name2" # App Name to be set +``` +### Assets +The config `whitelabel.yaml` can contain a few Asset items (every added Xcode project can have its own Assets). +Every Asset item can be configured with images, colors, and app Icon Assets: +```yaml +assets: + AssetName: + images_path: 'Theme/Theme/Assets.xcassets' # path where images are placed in this Asset + colors_path: 'Theme/Theme/Assets.xcassets/Colors' # path where colors are placed in this Asset + icon_path: 'Theme/Assets.xcassets' # path where app icon is placed in this Asset + images: + image1: # Asset name + image_name: 'some_image.svg' # image to replace the existing one for image1 Asset (light/universal) + image2: # Asset name + current_path: 'SomeFolder' # Path to image2.imageset inside Assets.xcassets + image_name: 'Rectangle.png' # image to replace the existing one for image2 Asset (light/universal) + dark_image_name: 'RectangleDark.png' # image to replace the existing dark appearance for image2 Asset (dark) + colors: + LoginBackground: # color asset name in Assets + current_path: '' # optional: path to color inside colors_path + light: '#FFFFFF' + dark: '#ED5C13' + icon: + AppIcon: + current_path: '' # optional: path to icon inside icon_path + image_name: 'appIcon.jpg' # image to replace the current AppIcon - png or jpg are supported +``` +### Log level +You can set the log level to 'DEBUG' by adding the `-v` parameter to the script running. +The default log level is 'WARN' +## \ No newline at end of file diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 563d587f1..8b6b5bd1f 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -37,11 +37,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { initDI() if let config = Container.shared.resolve(ConfigProtocol.self) { + Theme.Shapes.isRoundedCorners = config.theme.isRoundedCorners if let configuration = config.firebase.firebaseOptions { FirebaseApp.configure(options: configuration) Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) } - if config.facebook.enabled { ApplicationDelegate.shared.application( application, diff --git a/OpenEdX/Base.lproj/LaunchScreen.storyboard b/OpenEdX/Base.lproj/LaunchScreen.storyboard index 7bc7cbbed..5cb3986ad 100644 --- a/OpenEdX/Base.lproj/LaunchScreen.storyboard +++ b/OpenEdX/Base.lproj/LaunchScreen.storyboard @@ -1,9 +1,9 @@ - + - + @@ -26,7 +26,7 @@ - + @@ -39,8 +39,8 @@ - - + + diff --git a/Theme/Theme/Assets.xcassets/Colors/AccentButtonColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/AccentButtonColor.colorset/Contents.json new file mode 100644 index 000000000..00d59cb46 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/AccentButtonColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.408", + "red" : "0.235" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.976", + "green" : "0.471", + "red" : "0.329" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/LoginBackground.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/LoginBackground.colorset/Contents.json new file mode 100644 index 000000000..8fef18d07 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/LoginBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.184", + "green" : "0.129", + "red" : "0.098" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/LoginNavigationText.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/LoginNavigationText.colorset/Contents.json new file mode 100644 index 000000000..22c4bb0a8 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/LoginNavigationText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/SplashBackground.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/SplashBackground.colorset/Contents.json new file mode 100644 index 000000000..99fc4a9bb --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/SplashBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFE", + "green" : "0x7B", + "red" : "0x51" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2F", + "green" : "0x21", + "red" : "0x19" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index 5ce3fb3bf..b50937911 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -25,6 +25,7 @@ public typealias AssetImageTypeAlias = ImageAsset.Image // swiftlint:disable identifier_name line_length nesting type_body_length type_name public enum ThemeAssets { public static let authBackground = ImageAsset(name: "authBackground") + public static let accentButtonColor = ColorAsset(name: "AccentButtonColor") public static let accentColor = ColorAsset(name: "AccentColor") public static let alert = ColorAsset(name: "Alert") public static let avatarStroke = ColorAsset(name: "AvatarStroke") @@ -34,10 +35,13 @@ public enum ThemeAssets { public static let cardViewStroke = ColorAsset(name: "CardViewStroke") public static let certificateForeground = ColorAsset(name: "CertificateForeground") public static let commentCellBackground = ColorAsset(name: "CommentCellBackground") + public static let loginBackground = ColorAsset(name: "LoginBackground") + public static let loginNavigationText = ColorAsset(name: "LoginNavigationText") public static let shadowColor = ColorAsset(name: "ShadowColor") public static let snackbarErrorColor = ColorAsset(name: "SnackbarErrorColor") public static let snackbarErrorTextColor = ColorAsset(name: "SnackbarErrorTextColor") public static let snackbarInfoAlert = ColorAsset(name: "SnackbarInfoAlert") + public static let splashBackground = ColorAsset(name: "SplashBackground") public static let styledButtonBackground = ColorAsset(name: "StyledButtonBackground") public static let styledButtonText = ColorAsset(name: "StyledButtonText") public static let textPrimary = ColorAsset(name: "TextPrimary") diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index f593091a6..8e9ec9aab 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -14,9 +14,11 @@ public struct Theme { public struct Colors { public private(set) static var accentColor = ThemeAssets.accentColor.swiftUIColor + public private(set) static var accentButtonColor = ThemeAssets.accentButtonColor.swiftUIColor public private(set) static var alert = ThemeAssets.alert.swiftUIColor public private(set) static var avatarStroke = ThemeAssets.avatarStroke.swiftUIColor public private(set) static var background = ThemeAssets.background.swiftUIColor + public private(set) static var loginBackground = ThemeAssets.loginBackground.swiftUIColor public private(set) static var backgroundStroke = ThemeAssets.backgroundStroke.swiftUIColor public private(set) static var cardViewBackground = ThemeAssets.cardViewBackground.swiftUIColor public private(set) static var cardViewStroke = ThemeAssets.cardViewStroke.swiftUIColor @@ -36,6 +38,7 @@ public struct Theme { public private(set) static var textInputUnfocusedStroke = ThemeAssets.textInputUnfocusedStroke.swiftUIColor public private(set) static var warning = ThemeAssets.warning.swiftUIColor public private(set) static var white = ThemeAssets.white.swiftUIColor + public private(set) static var loginNavigationText = ThemeAssets.loginNavigationText.swiftUIColor public static func update( accentColor: Color = ThemeAssets.accentColor.swiftUIColor, @@ -112,10 +115,17 @@ public struct Theme { } public struct Shapes { + public static var isRoundedCorners: Bool = true public static let screenBackgroundRadius = 24.0 public static let cardImageRadius = 10.0 - public static let textInputShape = RoundedRectangle(cornerRadius: 8) - public static let buttonShape = RoundedCorners(tl: 8, tr: 8, bl: 8, br: 8) + public static let textInputShape = { + let radius: CGFloat = isRoundedCorners ? 8 : 0 + return RoundedRectangle(cornerRadius: radius) + }() + public static let buttonShape = { + let radius: CGFloat = isRoundedCorners ? 8 : 0 + return RoundedCorners(tl: radius, tr: radius, bl: radius, br: radius) + }() public static let unitButtonShape = RoundedCorners(tl: 21, tr: 21, bl: 21, br: 21) public static let roundedScreenBackgroundShape = RoundedCorners( tl: Theme.Shapes.screenBackgroundRadius, diff --git a/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift b/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift index abf6c95c9..828fcbf52 100644 --- a/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift +++ b/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift @@ -46,17 +46,21 @@ struct WhatsNewNavigationButton: View { }.padding(.horizontal, 20) .padding(.vertical, 9) }.fixedSize() - .background(type == .previous + .background( + Theme.Shapes.buttonShape + .fill( + type == .previous ? Theme.Colors.background - : Theme.Colors.accentColor) + : Theme.Colors.accentButtonColor + ) + ) .accessibilityElement(children: .ignore) .accessibilityLabel(type == .previous ? WhatsNewLocalization.buttonPrevious : (type == .next ? WhatsNewLocalization.buttonNext : WhatsNewLocalization.buttonDone )) - .cornerRadius(8) .overlay( - RoundedRectangle(cornerRadius: 8) + Theme.Shapes.buttonShape .stroke(type == .previous - ? Theme.Colors.accentColor + ? Theme.Colors.accentButtonColor : Theme.Colors.background, lineWidth: 1) ) .onTapGesture { action() } diff --git a/config_script/whitelabel.py b/config_script/whitelabel.py new file mode 100644 index 000000000..ef11674dd --- /dev/null +++ b/config_script/whitelabel.py @@ -0,0 +1,409 @@ +import argparse +import logging +import os +import shutil +import sys +import yaml +import json +import coloredlogs +from PIL import Image +import re +from textwrap import dedent + +class WhitelabelApp: + EXAMPLE_CONFIG_FILE = dedent(""" + # Notes: + # Config file can contain next options: + import_dir: 'path/to/asset/Images' # folder where importing images are placed + assets: + AssetName: + images_path: 'Theme/Theme/Assets.xcassets' # path where images are placed in this Asset + colors_path: 'Theme/Theme/Assets.xcassets/Colors' # path where colors are placed in this Asset + icon_path: 'Theme/Assets.xcassets' # path where the app icon is placed in this Asset + images: + image1: # Asset name + image_name: 'some_image.svg' # image to replace the existing one for image1 Asset (light/universal) + image2: # Asset name + current_path: 'SomeFolder' # Path to image2.imageset inside Assets.xcassets + image_name: 'Rectangle.png' # image to replace the existing one for image2 Asset (light/universal) + dark_image_name: 'RectangleDark.png' # image to replace the existing dark appearance for image2 Asset (dark) + colors: + LoginBackground: # color asset name in Assets + current_path: '' # optional: path to color inside colors_path + light: '#FFFFFF' + dark: '#ED5C13' + icon: + AppIcon: + current_path: '' # optional: path to icon inside icon_path + image_name: 'appIcon.jpg' # image to replace the current AppIcon - png or jpg are supported + project_config: + project_path: 'path/to/project/project.pbxproj' # path to project.pbxproj file + dev_team: '1234567890' # apple development team id + marketing_version: '1.0.1' # app marketing version + current_project_version: '2' # app build number + configurations: + config1: # build configuration name in project + app_bundle_id: "bundle.id.app.new1" # bundle ID which should be set + product_name: "Mobile App Name1" # app name which should be set + config2: # build configuration name in project + app_bundle_id: "bundle.id.app.new2" # bundle ID which should be set + product_name: "Mobile App Name2" # app name which should be set + """) + + def __init__(self, **kwargs): + self.assets_dir = kwargs.get('import_dir') + if not self.assets_dir: + self.assets_dir = '.' + + self.assets = kwargs.get('assets', {}) + self.project_config = kwargs.get('project_config', {}) + + if "project_path" in self.project_config: + self.config_project_path = self.project_config["project_path"] + else: + logging.error("Path to project file is not defined") + + def whitelabel(self): + # Update the properties, resources, and configuration of the current app. + self.copy_assets() + self.set_app_project_config() + + def copy_assets(self): + if self.assets: + for asset in self.assets.items(): + self.replace_images(asset) + self.replace_colors(asset) + self.replace_app_icon(asset) + else: + logging.debug("Assets not found") + + def replace_images(self, asset_data): + asset = asset_data[1] + asset_name = asset_data[0] + if "images" in asset : + asset_path = asset["images_path"] if "images_path" in asset else "" + for name, image in asset["images"].items(): + current_path = image["current_path"] if "current_path" in image else "" + path_to_imageset = os.path.join(asset_path, current_path, name+'.imageset') + content_json_path = os.path.join(path_to_imageset, 'Contents.json') + image_name_original = '' + dark_image_name_original = '' + with open(content_json_path, 'r') as openfile: + json_object = json.load(openfile) + for json_image in json_object["images"]: + if "appearances" in json_image: + # dark + dark_image_name_original = json_image["filename"] + else: + # light + image_name_original = json_image["filename"] + has_dark = True if "dark_image_name" in image else False + image_name_import = image["image_name"] if "image_name" in image else '' + dark_image_name_import = image["dark_image_name"] if "dark_image_name" in image else '' + + # conditions to start updating + file_path = os.path.join(path_to_imageset, image_name_original) + dark_file_path = os.path.join(path_to_imageset, dark_image_name_original) + files_to_changes_exist = os.path.exists(file_path) and image_name_original != '' # 1 + if has_dark: + files_to_changes_exist = files_to_changes_exist and os.path.exists(dark_file_path) and dark_image_name_original != '' + contents_json_is_good = os.path.exists(content_json_path) and image_name_original != '' # 2 + if has_dark: + contents_json_is_good = contents_json_is_good and dark_image_name_original != '' + + path_to_imageset_exists = os.path.exists(path_to_imageset) # 3 + file_to_copy_path = os.path.join(self.assets_dir, image_name_import) + dark_file_to_copy_path = os.path.join(self.assets_dir, dark_image_name_import) + files_to_copy_exist = os.path.exists(file_to_copy_path) # 4 + if has_dark: + files_to_copy_exist = files_to_copy_exist and os.path.exists(dark_file_to_copy_path) + + if files_to_changes_exist and contents_json_is_good and path_to_imageset_exists and files_to_copy_exist: + # Delete current file(s) + os.remove(file_path) + if has_dark: + os.remove(dark_file_path) + # Change Contents.json + with open(content_json_path, 'r') as openfile: + contents_string = openfile.read() + contents_string = contents_string.replace(image_name_original, image_name_import) + if has_dark: + contents_string = contents_string.replace(dark_image_name_original, dark_image_name_import) + with open(content_json_path, 'w') as openfile: + openfile.write(contents_string) + # Copy new file(s) + shutil.copy(file_to_copy_path, path_to_imageset) + logging.debug(asset_name+"->images->"+name+": 'light mode'/universal image was updated with "+image_name_import) + if has_dark: + shutil.copy(dark_file_to_copy_path, path_to_imageset) + logging.debug(asset_name+"->images->"+name+": 'dark mode' image was updated with "+dark_image_name_import) + else: + # Handle errors + if not files_to_changes_exist: + logging.error(asset_name+"->images->"+name+": original file(s) doesn't exist") + elif not contents_json_is_good: + logging.error(asset_name+"->images->"+name+": Contents.json doesn't exist or wrong original file(s) in config") + elif not path_to_imageset_exists: + logging.error(asset_name+"->images->"+name+": "+ path_to_imageset + " doesn't exist") + elif not files_to_copy_exist: + logging.error(asset_name+"->images->"+name+": file(s) to copy doesn't exist") + + def replace_colors(self, asset_data): + asset = asset_data[1] + asset_name = asset_data[0] + if "colors" in asset: + colors_path = asset["colors_path"] if "colors_path" in asset else "" + for name, color in asset["colors"].items(): + current_path = color["current_path"] if "current_path" in color else "" + path_to_colorset = os.path.join(colors_path, current_path, name+'.colorset') + light_color = color["light"] + dark_color = color["dark"] + # Change Contents.json + content_json_path = os.path.join(path_to_colorset, 'Contents.json') + if os.path.exists(content_json_path): + with open(content_json_path, 'r') as openfile: + json_object = json.load(openfile) + for key in range(len(json_object["colors"])): + if "appearances" in json_object["colors"][key]: + # dark + changed_components = self.change_color_components(json_object["colors"][key]["color"]["components"], dark_color, name) + json_object["colors"][key]["color"]["components"] = changed_components + else: + # light + changed_components = self.change_color_components(json_object["colors"][key]["color"]["components"], light_color, name) + json_object["colors"][key]["color"]["components"] = changed_components + new_json = json.dumps(json_object) + with open(content_json_path, 'w') as openfile: + openfile.write(new_json) + logging.debug(asset_name+"->colors->"+name+": color was updated with light:'"+light_color+"' dark:'"+dark_color+"'") + else: + logging.error(asset_name+"->colors->"+name+": " + content_json_path + " doesn't exist") + + def change_color_components(self, components, color, name): + color = color.replace("#", "") + if len(color) != 6: + print('Config for color "'+name+'" is incorrect') + else: + components["red"] = "0x"+color[0]+color[1] + components["green"] = "0x"+color[2]+color[3] + components["blue"] = "0x"+color[4]+color[5] + return components + + def replace_app_icon(self, asset_data): + asset = asset_data[1] + asset_name = asset_data[0] + if "icon" in asset: + icon_path = asset["icon_path"] if "icon_path" in asset else "" + for name, icon in asset["icon"].items(): + current_path = icon["current_path"] if "current_path" in icon else "" + path_to_iconset = os.path.join(icon_path, current_path, name+'.appiconset') + content_json_path = os.path.join(path_to_iconset, 'Contents.json') + with open(content_json_path, 'r') as openfile: + json_object = json.load(openfile) + json_icon = json_object["images"][0] + file_to_change = json_icon["filename"] + size_to_change = json_icon["size"] + file_to_copy = icon["image_name"] + file_to_copy_path = os.path.join(self.assets_dir, file_to_copy) + file_to_change_path = os.path.join(path_to_iconset, file_to_change) + if os.path.exists(file_to_change_path): + if os.path.exists(file_to_copy_path): + # get new file width and height + img = Image.open(file_to_copy_path) + # get width and height + width = img.width + height = img.height + # Delete current file + os.remove(file_to_change_path) + # Change Contents.json + with open(content_json_path, 'r') as openfile: + contents_string = openfile.read() + contents_string = contents_string.replace(file_to_change, file_to_copy) + contents_string = contents_string.replace(size_to_change, str(width)+'x'+str(height)) + with open(content_json_path, 'w') as openfile: + openfile.write(contents_string) + # Copy new file + shutil.copy(file_to_copy_path, path_to_iconset) + logging.debug(asset_name+"->icon->"+name+": app icon was updated with "+file_to_copy) + else: + logging.error(asset_name+"->icon->"+name+": " + file_to_copy_path + " doesn't exist") + else: + logging.error(asset_name+"->icon->"+name+": " + file_to_change_path + " doesn't exist") + + def set_app_project_config(self): + self.set_build_related_params() + self.set_project_global_params() + + def set_build_related_params(self): + # check if configurations exist + if "configurations" in self.project_config: + configurations = self.project_config["configurations"] + # read project file + with open(self.config_project_path, 'r') as openfile: + config_file_string = openfile.read() + errors_texts = [] + for name, config in configurations.items(): + # replace parameters for every config + config_file_string = self.replace_parameter_in_config("app_bundle_id", config_file_string, config, name, errors_texts) + config_file_string = self.replace_parameter_in_config("product_name", config_file_string, config, name, errors_texts) + # write to project file + with open(self.config_project_path, 'w') as openfile: + openfile.write(config_file_string) + # print success message or errors if are presented + if len(errors_texts) == 0: + logging.debug("Project configurations parameters were successfully changed") + else: + for error in errors_texts: + logging.error(error) + else: + logging.error("Project configuration is not defined") + + def replace_parameter_in_config(self, parameter, config_file_string, config, config_name, errors_texts): + # if parameter is configured + if parameter in config: + parameter_value = config[parameter] + # if parameter's value is not empty + if parameter_value != '' and parameter_value is not None: + parameter_string = '' + parameter_regex = '' + # define regex rule and replacement string for every possible parameter + if parameter == "app_bundle_id": + parameter_string = "PRODUCT_BUNDLE_IDENTIFIER = "+parameter_value+";" + parameter_regex = "PRODUCT_BUNDLE_IDENTIFIER = .*;" + elif parameter == "product_name": + parameter_string = "PRODUCT_NAME = \""+parameter_value+"\";" + parameter_regex = "PRODUCT_NAME = \".*\";" + # if regex is defined + if parameter_regex != '': + # replace parameter in config file + config_file_string = self.replace_parameter_for_build_config(config_file_string, config_name, parameter_string, parameter_regex, errors_texts) + else: + errors_texts.append("project_config->configurations->"+config_name+": Regex rule for '"+parameter+"' is not defined in config script") + else: + errors_texts.append("project_config->configurations->"+config_name+": '"+parameter+"' parameter is empty in config") + else: + errors_texts.append("project_config->configurations->"+config_name+": '"+parameter+"' was not found in config") + return config_file_string + + def replace_parameter_for_build_config(self, config_file_string, config_name, new_param_string, search_param_regex, errors_texts): + # search substring for current build config only + search_string = re.search(self.regex_string_for_build_config(config_name), config_file_string) + # if build config is found + if search_string is not None: + # get build config as string + config_string = search_string.group() + config_string_out = config_string + # search parameter in config_string + parameter_search_string = re.search(search_param_regex, config_string) + if parameter_search_string is not None: + # get parameter_string as string + parameter_string = parameter_search_string.group() + # replace existing parameter value with new value + config_string_out = config_string.replace(parameter_string, new_param_string) + else: + errors_texts.append("project_config->configurations->"+config_name+": Check regex please. Can't find place in project file where insert '"+new_param_string+"'") + # if something found + if config_string != config_string_out: + config_file_string = config_file_string.replace(config_string, config_string_out) + else: + errors_texts.append("project_config->configurations->"+config_name+": not found in project file") + return config_file_string + + def regex_string_for_build_config(self, build_config): + # regex to search build config inside project file + return f"/\\* {build_config} \\*/ = {{\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = [\\s|\\S]*\t\t\tname = {build_config};" + + def set_project_global_params(self): + # set values for 'global' parameters + self.set_global_parameter("dev_team") + self.set_global_parameter("marketing_version") + self.set_global_parameter("current_project_version") + + def set_global_parameter(self, parameter): + # if parameter is defined in config + if parameter in self.project_config: + parameter_value = self.project_config[parameter] + # if parameter value is not empty + if parameter_value != '' and parameter_value is not None: + # read project file + with open(self.config_project_path, 'r') as openfile: + config_file_string = openfile.read() + config_file_string_out = config_file_string + parameter_string = '' + parameter_regex = '' + # define regex rule and replacement string for every possible parameter + if parameter == "dev_team": + parameter_string = 'DEVELOPMENT_TEAM = '+parameter_value+';' + parameter_regex = 'DEVELOPMENT_TEAM = .{10};' + elif parameter == "marketing_version": + parameter_string = 'MARKETING_VERSION = '+parameter_value+';' + parameter_regex = 'MARKETING_VERSION = .*;' + elif parameter == "current_project_version": + parameter_string = 'CURRENT_PROJECT_VERSION = '+parameter_value+';' + parameter_regex = 'CURRENT_PROJECT_VERSION = .*;' + # if regex is defined + if parameter_regex != '': + # replace all regex findings with new parameters string + config_file_string_out = re.sub(parameter_regex, parameter_string, config_file_string) + else: + logging.error("Regex rule for '"+parameter+"' is not defined in config script") + # if any entries were found and replaced + if config_file_string_out != config_file_string: + # write to project file + with open(self.config_project_path, 'w') as openfile: + openfile.write(config_file_string_out) + logging.debug("'"+parameter+"' was set successfuly") + # if nothing was found + elif re.search(parameter_regex, config_file_string) is None: + logging.error("Check regex please. Nothing was found for '"+parameter+"' in project file") + # if parameter was found but it's replaced already + elif re.search(parameter_regex, config_file_string).group() == parameter_string and parameter_string != '': + logging.debug("Looks like '"+parameter+"' is set already") + # if parameter was not found and it's not empty + elif parameter_string != '': + logging.error("No '"+parameter+"' is found in project file") + else: + logging.error("'"+parameter+"' is empty in config") + else: + logging.error("'"+parameter+"' is not defined") + +def main(): + """ + Parse the command line arguments, and pass them to WhitelabelApp. + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--help-config-file', action='store_true', help="Print out a sample config-file, and exit") + parser.add_argument('--config-file', '-c', help="Path to the configuration file") + parser.add_argument('--verbose', '-v', action='count', help="Enable verbose logging.") + args = parser.parse_args() + + if args.help_config_file: + print(WhitelabelApp.EXAMPLE_CONFIG_FILE) + sys.exit(0) + + if not args.config_file: + parser.print_help() + sys.exit(1) + + if args.verbose is None: + args.verbose = 0 + log_level = logging.WARN + if args.verbose > 0: + log_level = logging.DEBUG + logging.basicConfig(level=log_level) + logger = logging.getLogger(name='whitelabel_config') + coloredlogs.install(level=log_level, logger=logger) + + with open(args.config_file) as f: + config = yaml.safe_load(f) or {} + + # Use the config_file's directory as the default config_dir + config.setdefault('config_dir', os.path.dirname(args.config_file)) + + whitelabeler = WhitelabelApp(**config) + whitelabeler.whitelabel() + + +if __name__ == "__main__": + main() + \ No newline at end of file