diff --git a/Packages/DomainModels/Sources/DomainModels/AppTheme.swift b/Packages/DomainModels/Sources/DomainModels/AppTheme.swift new file mode 100644 index 0000000..a15366a --- /dev/null +++ b/Packages/DomainModels/Sources/DomainModels/AppTheme.swift @@ -0,0 +1,13 @@ +// +// File.swift +// +// +// Created by Oscar Gonzalez on 09/03/23. +// + +import Foundation + +public enum AppTheme: Equatable { + case free + case pro +} diff --git a/Packages/Feature/Sources/Feature/CountryDetails/Components/CountryDetailsContent.swift b/Packages/Feature/Sources/Feature/CountryDetails/Components/CountryDetailsContent.swift index 392fe30..92d062d 100644 --- a/Packages/Feature/Sources/Feature/CountryDetails/Components/CountryDetailsContent.swift +++ b/Packages/Feature/Sources/Feature/CountryDetails/Components/CountryDetailsContent.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI import DomainModels import Utils +import UIComponents /// Content is a component of a page. Content accepts bindings or simple primitive types. public struct CountryDetailsContent: View { @@ -34,5 +35,6 @@ public struct CountryDetailsContent: View { struct CountryDetailsContent_Previews: PreviewProvider { static var previews: some View { CountryDetailsContent(countryName: "United States", detailsText: "Now is the time for all good men to come to the aid of their country.") + .theme(ProTheme()) } } diff --git a/Packages/Feature/Sources/Feature/CountryDetails/Components/CountryNotFoundErrorView.swift b/Packages/Feature/Sources/Feature/CountryDetails/Components/CountryNotFoundErrorView.swift index ac28deb..06e2d24 100644 --- a/Packages/Feature/Sources/Feature/CountryDetails/Components/CountryNotFoundErrorView.swift +++ b/Packages/Feature/Sources/Feature/CountryDetails/Components/CountryNotFoundErrorView.swift @@ -48,5 +48,6 @@ public struct CountryNotFoundErrorView: View { struct CountryNotFoundErrorView_Previews: PreviewProvider { static var previews: some View { CountryNotFoundErrorView(viewModelState: .error(error: .countryNotFound)) + .theme(ProTheme()) } } diff --git a/Packages/Feature/Sources/Feature/DI/Container+Features.swift b/Packages/Feature/Sources/Feature/DI/Container+Features.swift index 485be16..60e9a2a 100644 --- a/Packages/Feature/Sources/Feature/DI/Container+Features.swift +++ b/Packages/Feature/Sources/Feature/DI/Container+Features.swift @@ -10,10 +10,13 @@ import Swinject import Interfaces import SwinjectAutoregistration import Repositories +import UIComponents public extension Container { func injectBusinessLogicLocalApis() -> Container { self.autoregister(AppSchedulerProviding.self, initializer: AppSchedulerProvider.init).inObjectScope(.container) + self.autoregister(Stylesheet.self, initializer: Stylesheet.init).inObjectScope(.container) + self.autoregister(ThemeMediator.self, initializer: ThemeMediator.init).inObjectScope(.container) return self } diff --git a/Packages/Feature/Sources/Feature/Theme modifier/InjectThemeModifier.swift b/Packages/Feature/Sources/Feature/Theme modifier/InjectThemeModifier.swift new file mode 100644 index 0000000..cda7bd8 --- /dev/null +++ b/Packages/Feature/Sources/Feature/Theme modifier/InjectThemeModifier.swift @@ -0,0 +1,33 @@ +// +// File.swift +// +// +// Created by Oscar Gonzalez on 13/04/23. +// + +import Foundation +import SwiftUI +import Utils +import UIComponents + +/// Injects the theme into the view environment +/// +/// The use of this modifier is intended for view adapters to avoid +/// using DI on previews +public struct InjectThemeModifier: ViewModifier { + @InjectStateObject var styleSheet: Stylesheet + + var theme: ThemeImplementing { + styleSheet.currentTheme + } + + public func body(content: Content) -> some View { + content.theme(theme) + } +} + +public extension View { + func themeInjected() -> some View { + self.modifier(InjectThemeModifier()) + } +} diff --git a/Packages/Feature/Sources/Feature/Theme modifier/ThemeMediator.swift b/Packages/Feature/Sources/Feature/Theme modifier/ThemeMediator.swift new file mode 100644 index 0000000..abdbb8c --- /dev/null +++ b/Packages/Feature/Sources/Feature/Theme modifier/ThemeMediator.swift @@ -0,0 +1,42 @@ +// +// File.swift +// +// +// Created by Oscar Gonzalez on 10/03/23. +// + +import Foundation +import Logic +import UIComponents +import DomainModels +import Utils +import Combine + +public class ThemeMediator { + + @Inject private var stylesheet: Stylesheet + @Inject private var themeLogic: AppThemeLogic + + var cancellables = Set() + + public init() { + stylesheet.currentTheme = ThemeFactory.build(theme: themeLogic.currentTheme) + + themeLogic + .$currentTheme + .dropFirst() + .map { ThemeFactory.build(theme: $0) } + .assign(to: &stylesheet.$currentTheme) + } +} + +public struct ThemeFactory { + public static func build(theme: AppTheme) -> ThemeImplementing { + switch theme { + case .free: + return FreeTheme() + case .pro: + return ProTheme() + } + } +} diff --git a/Packages/Logic/Sources/Logic/AppThemeLogic.swift b/Packages/Logic/Sources/Logic/AppThemeLogic.swift new file mode 100644 index 0000000..cb558d0 --- /dev/null +++ b/Packages/Logic/Sources/Logic/AppThemeLogic.swift @@ -0,0 +1,29 @@ +// +// File.swift +// +// +// Created by Oscar Gonzalez on 09/03/23. +// + +import Foundation +import DomainModels +import Combine +import Repositories + +public class AppThemeLogic { + + @Published public private(set) var currentTheme: AppTheme + + private let appThemeRepository: AppThemeRepository + + public init(appThemeRepository: AppThemeRepository) { + self.appThemeRepository = appThemeRepository + + currentTheme = appThemeRepository.currentTheme + appThemeRepository.$currentTheme.dropFirst().assign(to: &$currentTheme) + } + + public func changeAppTheme(to theme: AppTheme) { + return appThemeRepository.setTheme(theme) + } +} diff --git a/Packages/Logic/Sources/Logic/DI/Container+BusinessLogic.swift b/Packages/Logic/Sources/Logic/DI/Container+BusinessLogic.swift index 2e01fce..9dba3ed 100644 --- a/Packages/Logic/Sources/Logic/DI/Container+BusinessLogic.swift +++ b/Packages/Logic/Sources/Logic/DI/Container+BusinessLogic.swift @@ -13,6 +13,7 @@ import Repositories public extension Container { func injectBusinessLogicLogic() -> Container { + self.autoregister(AppThemeLogic.self, initializer: AppThemeLogic.init).inObjectScope(.transient) self.autoregister(CountryListLogic.self, initializer: CountryListLogic.init).inObjectScope(.transient) self.autoregister(CountryDetailsLogic.self, initializer: CountryDetailsLogic.init).inObjectScope(.transient) self.autoregister(ServerStatusLogic.self, initializer: ServerStatusLogic.init).inObjectScope(.transient) @@ -21,6 +22,7 @@ public extension Container { } func injectBusinessLogicRepositories() -> Container { + self.autoregister(AppThemeRepository.self, initializer: AppThemeRepository.init).inObjectScope(.container) self.autoregister(CountryListRepository.self, initializer: CountryListRepository.init).inObjectScope(.container) self.autoregister(CountryDetailsRepository.self, initializer: CountryDetailsRepository.init).inObjectScope(.container) self.autoregister(ServerStatusPushBasedRepository.self, initializer: ServerStatusPushBasedRepository.init).inObjectScope(.container) diff --git a/Packages/Repositories/Sources/Repositories/AppThemeRepository.swift b/Packages/Repositories/Sources/Repositories/AppThemeRepository.swift new file mode 100644 index 0000000..c0c7463 --- /dev/null +++ b/Packages/Repositories/Sources/Repositories/AppThemeRepository.swift @@ -0,0 +1,19 @@ +// +// File.swift +// +// +// Created by Oscar Gonzalez on 09/03/23. +// + +import Foundation +import DomainModels + +public class AppThemeRepository { + @Published public private(set) var currentTheme = AppTheme.free + + public init() {} + + public func setTheme(_ appTheme: AppTheme) { + currentTheme = appTheme + } +} diff --git a/Packages/Repositories/Sources/Repositories/DI/Container+Repositories.swift b/Packages/Repositories/Sources/Repositories/DI/Container+Repositories.swift index 40ecce0..e7147b3 100644 --- a/Packages/Repositories/Sources/Repositories/DI/Container+Repositories.swift +++ b/Packages/Repositories/Sources/Repositories/DI/Container+Repositories.swift @@ -12,6 +12,7 @@ import SwinjectAutoregistration public extension Container { func injectBusinessLogicRepositories() -> Container { + self.autoregister(AppThemeRepository.self, initializer: AppThemeRepository.init).inObjectScope(.container) self.autoregister(CountryListRepository.self, initializer: CountryListRepository.init).inObjectScope(.container) self.autoregister(CountryDetailsRepository.self, initializer: CountryDetailsRepository.init).inObjectScope(.container) self.autoregister(ServerStatusPushBasedRepository.self, initializer: ServerStatusPushBasedRepository.init).inObjectScope(.container) diff --git a/Packages/UIComponents/Sources/UIComponents/Stylesheet/ColorPalette.swift b/Packages/UIComponents/Sources/UIComponents/Stylesheet/ColorPalette.swift new file mode 100644 index 0000000..0f4c575 --- /dev/null +++ b/Packages/UIComponents/Sources/UIComponents/Stylesheet/ColorPalette.swift @@ -0,0 +1,36 @@ +// +// SwiftUIView.swift +// +// +// Created by Oscar Gonzalez on 08/03/23. +// + +import SwiftUI + +struct ColorPalette { + + struct malachite { + static let brightness500 = Color(hex: 0x06D16F) + static let brightness700 = Color(hex: 0x00A756) + } + + struct bearHoney { + static let brightness500 = Color(hex: 0xE9A142) + static let brightness700 = Color(hex: 0xDA7C00) + } + + struct orange { + static let brightness500 = Color(hex: 0xEC7C3C) + static let brightness700 = Color(hex: 0xBB4C0C) + } + + static let blastoise = Color(hex: 0x007AFF) + static let charizard = Color(hex: 0xFA0002) + static let latexPurple = Color(hex: 0x7708E7) + static let darkGray = Color(hex: 0x1E1E1E) + static let lightGray = Color(hex: 0xD2D2D2) + static let midgray = Color(hex: 0x4D4D4D) + static let white = Color.white + static let black = Color.black + static let green = Color(hex: 0x00FF00) +} diff --git a/Packages/UIComponents/Sources/UIComponents/Stylesheet/EnvironmentValues+Theme.swift b/Packages/UIComponents/Sources/UIComponents/Stylesheet/EnvironmentValues+Theme.swift new file mode 100644 index 0000000..87e2d93 --- /dev/null +++ b/Packages/UIComponents/Sources/UIComponents/Stylesheet/EnvironmentValues+Theme.swift @@ -0,0 +1,21 @@ +// +// File.swift +// +// +// Created by Oscar Gonzalez on 09/03/23. +// + +import Foundation +import SwiftUI +import Utils + +public struct ThemeKey: EnvironmentKey { + public static var defaultValue: ThemeImplementing = FreeTheme() +} + +public extension EnvironmentValues { + var theme: ThemeImplementing { + get { self[ThemeKey.self] } + set { self[ThemeKey.self] = newValue } + } +} diff --git a/Packages/UIComponents/Sources/UIComponents/Stylesheet/ThemeModifier.swift b/Packages/UIComponents/Sources/UIComponents/Stylesheet/ThemeModifier.swift new file mode 100644 index 0000000..2058403 --- /dev/null +++ b/Packages/UIComponents/Sources/UIComponents/Stylesheet/ThemeModifier.swift @@ -0,0 +1,28 @@ +// +// File.swift +// +// +// Created by Oscar Gonzalez on 24/03/23. +// + +import Foundation +import SwiftUI +import Utils + +public struct ThemeModifier: ViewModifier { + var theme: ThemeImplementing + + public func body(content: Content) -> some View { + content + .accentColor(theme.color.accent) + .foregroundColor(theme.color.content) + .preferredColorScheme(theme.color.scheme) + .environment(\.theme, theme) + } +} + +public extension View { + func theme(_ theme: ThemeImplementing) -> some View { + self.modifier(ThemeModifier(theme: theme)) + } +} diff --git a/Packages/UIComponents/Sources/UIComponents/Stylesheet/Themes/FreeTheme.swift b/Packages/UIComponents/Sources/UIComponents/Stylesheet/Themes/FreeTheme.swift new file mode 100644 index 0000000..c731924 --- /dev/null +++ b/Packages/UIComponents/Sources/UIComponents/Stylesheet/Themes/FreeTheme.swift @@ -0,0 +1,21 @@ +// +// File.swift +// +// +// Created by Oscar Gonzalez on 08/03/23. +// + +import Foundation +import SwiftUI + +public struct FreeTheme: ThemeImplementing { + public struct Color: ThemeColor { + public let accent = ColorPalette.blastoise + public let content = ColorPalette.black + public let scheme: ColorScheme = .light + } + + public let color: ThemeColor = FreeTheme.Color() + + public init() {} +} diff --git a/Packages/UIComponents/Sources/UIComponents/Stylesheet/Themes/ProTheme.swift b/Packages/UIComponents/Sources/UIComponents/Stylesheet/Themes/ProTheme.swift new file mode 100644 index 0000000..bb6eb7e --- /dev/null +++ b/Packages/UIComponents/Sources/UIComponents/Stylesheet/Themes/ProTheme.swift @@ -0,0 +1,21 @@ +// +// File.swift +// +// +// Created by Oscar Gonzalez on 08/03/23. +// + +import Foundation +import SwiftUI + +public struct ProTheme: ThemeImplementing { + public struct Color: ThemeColor { + public let accent = ColorPalette.charizard + public let content = ColorPalette.white + public let scheme: ColorScheme = .dark + } + + public let color: ThemeColor = ProTheme.Color() + + public init() {} +} diff --git a/Packages/UIComponents/Sources/UIComponents/Stylesheet/Themes/Stylesheet.swift b/Packages/UIComponents/Sources/UIComponents/Stylesheet/Themes/Stylesheet.swift new file mode 100644 index 0000000..83276fa --- /dev/null +++ b/Packages/UIComponents/Sources/UIComponents/Stylesheet/Themes/Stylesheet.swift @@ -0,0 +1,14 @@ +// +// File.swift +// +// +// Created by Oscar Gonzalez on 10/03/23. +// + +import Foundation + +public class Stylesheet: ObservableObject { + @Published public var currentTheme: ThemeImplementing = FreeTheme() + + public init() {} +} diff --git a/Packages/UIComponents/Sources/UIComponents/Stylesheet/Themes/ThemeImplementing.swift b/Packages/UIComponents/Sources/UIComponents/Stylesheet/Themes/ThemeImplementing.swift new file mode 100644 index 0000000..80ff31f --- /dev/null +++ b/Packages/UIComponents/Sources/UIComponents/Stylesheet/Themes/ThemeImplementing.swift @@ -0,0 +1,19 @@ +// +// File.swift +// +// +// Created by Oscar Gonzalez on 08/03/23. +// + +import Foundation +import SwiftUI + +public protocol ThemeImplementing { + var color: ThemeColor { get } +} + +public protocol ThemeColor { + var accent: Color { get } + var content: Color { get } + var scheme: ColorScheme { get } +} diff --git a/Packages/UIComponents/Sources/UIComponents/Utils/Color+Hex.swift b/Packages/UIComponents/Sources/UIComponents/Utils/Color+Hex.swift new file mode 100644 index 0000000..98d7978 --- /dev/null +++ b/Packages/UIComponents/Sources/UIComponents/Utils/Color+Hex.swift @@ -0,0 +1,21 @@ +// +// File.swift +// +// +// Created by Oscar Gonzalez on 08/03/23. +// + +import Foundation +import SwiftUI + +extension Color { + init(hex: UInt, alpha: Double = 1) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xff) / 255, + green: Double((hex >> 08) & 0xff) / 255, + blue: Double((hex >> 00) & 0xff) / 255, + opacity: alpha + ) + } +} diff --git a/Packages/ViewModels/Sources/ViewModels/AppThemeViewModel.swift b/Packages/ViewModels/Sources/ViewModels/AppThemeViewModel.swift new file mode 100644 index 0000000..d14a3bd --- /dev/null +++ b/Packages/ViewModels/Sources/ViewModels/AppThemeViewModel.swift @@ -0,0 +1,33 @@ +// +// File.swift +// +// +// Created by Oscar Gonzalez on 09/03/23. +// + +import Foundation +import DomainModels +import Combine +import Logic + +public class AppThemeViewModel: ObservableObject { + @Published public private(set) var currentTheme: AppTheme + + private var appThemeLogic: AppThemeLogic + + init(appThemeLogic: AppThemeLogic) { + self.appThemeLogic = appThemeLogic + + currentTheme = appThemeLogic.currentTheme + appThemeLogic.$currentTheme.dropFirst().assign(to: &$currentTheme) + } + + public func toggleTheme() { + switch currentTheme { + case .free: + appThemeLogic.changeAppTheme(to: .pro) + case .pro: + appThemeLogic.changeAppTheme(to: .free) + } + } +} diff --git a/Packages/ViewModels/Sources/ViewModels/DI/Container+ViewModels.swift b/Packages/ViewModels/Sources/ViewModels/DI/Container+ViewModels.swift index 4635808..43629ff 100644 --- a/Packages/ViewModels/Sources/ViewModels/DI/Container+ViewModels.swift +++ b/Packages/ViewModels/Sources/ViewModels/DI/Container+ViewModels.swift @@ -14,6 +14,7 @@ import Logic public extension Container { func injectBusinessLogicViewModels() -> Container { self.autoregister(AboutViewModel.self, initializer: AboutViewModel.init).inObjectScope(.transient) + self.autoregister(AppThemeViewModel.self, initializer: AppThemeViewModel.init).inObjectScope(.transient) self.autoregister(CountryListViewModel.self, initializer: CountryListViewModel.init).inObjectScope(.transient) self.register(CountryDetailsViewModel.self) { resolver, country in CountryDetailsViewModel(country: country, diff --git a/Shared/ContentView.swift b/Shared/ContentView.swift index 8277096..f390e29 100644 --- a/Shared/ContentView.swift +++ b/Shared/ContentView.swift @@ -8,15 +8,13 @@ import SwiftUI import UIComponents import Utils +import ViewModels struct ContentView: View { + + @Environment(\.theme) private var theme: ThemeImplementing + var body: some View { TravelAdvisoriesNavHost() } } - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } -} diff --git a/Shared/TravelAdvisoriesApp.swift b/Shared/TravelAdvisoriesApp.swift index 8d1a42b..70f9459 100644 --- a/Shared/TravelAdvisoriesApp.swift +++ b/Shared/TravelAdvisoriesApp.swift @@ -10,9 +10,13 @@ import Swinject import Utils import Interfaces import NetworkLogic +import Feature +import UIComponents @main struct TravelAdvisoriesApp: App { + private var themeMediator: ThemeMediator + init() { InjectSettings.resolver = Container() .injectBusinessLogicViewModels() @@ -20,11 +24,13 @@ struct TravelAdvisoriesApp: App { .injectBusinessLogicRepositories() .injectBusinessLogicLocalApis() .injectNetworkLogicRemoteApis() + + themeMediator = InjectSettings.resolver!.resolve(ThemeMediator.self)! } var body: some Scene { WindowGroup { - ContentView() + ContentView().themeInjected() } } } diff --git a/Shared/TravelAdvisoriesNavHost.swift b/Shared/TravelAdvisoriesNavHost.swift index efa8c31..b2e272b 100644 --- a/Shared/TravelAdvisoriesNavHost.swift +++ b/Shared/TravelAdvisoriesNavHost.swift @@ -13,20 +13,20 @@ import DomainModels import Feature import ViewModels import Swinject +import UIComponents public struct TravelAdvisoriesNavHost: View { - private let resolver: Swinject.Resolver - - @State var destination: Destinations? - + enum Destinations { case details(regionCode: String) case aboutThisApp } - public init(resolver: Swinject.Resolver = InjectSettings.resolver!) { - self.resolver = resolver - } + @Environment(\.theme) private var theme: ThemeImplementing + @InjectStateObject private var themeViewModel: AppThemeViewModel + @State var destination: Destinations? + + public init() {} public var body: some View { NavigationView { @@ -41,6 +41,14 @@ public struct TravelAdvisoriesNavHost: View { self.buildBaseView() } + .toolbar { + Button { + themeViewModel.toggleTheme() + } label: { + Text("Change theme") + .foregroundColor(theme.color.accent) + } + } } }