Commit 73c257df by Dmitry Stepanets

Merge branch 'feature/minutely/IOS-172-minutely-cell-merge-54' into…

Merge branch 'feature/minutely/IOS-172-minutely-cell-merge-54' into feature/minutely/IOS-172-minutely-cell

# Conflicts:
#	1Weather/UI/SharedCells/PrecipCell/PrecipitationCell.swift
#	1Weather/UI/View controllers/Today/Cells/TodayForecastTimePeriodCell.swift
#	OneWeatherAnalytics/OneWeatherAnalytics/AnalyticsEvent.swift
parents efb9810d 31b6a632
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1250" LastUpgradeVersion = "1300"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
...@@ -50,6 +50,9 @@ ...@@ -50,6 +50,9 @@
ReferencedContainer = "container:1Weather.xcodeproj"> ReferencedContainer = "container:1Weather.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../1Weather/InApps/Configuration.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"
...@@ -72,7 +75,7 @@ ...@@ -72,7 +75,7 @@
buildConfiguration = "Debug"> buildConfiguration = "Debug">
</AnalyzeAction> </AnalyzeAction>
<ArchiveAction <ArchiveAction
buildConfiguration = "Release" buildConfiguration = "Debug"
revealArchiveInOrganizer = "YES"> revealArchiveInOrganizer = "YES">
</ArchiveAction> </ArchiveAction>
</Scheme> </Scheme>
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1250" LastUpgradeVersion = "1300"
wasCreatedForAppExtension = "YES" wasCreatedForAppExtension = "YES"
version = "2.0"> version = "2.0">
<BuildAction <BuildAction
......
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1250" LastUpgradeVersion = "1300"
wasCreatedForAppExtension = "YES" wasCreatedForAppExtension = "YES"
version = "2.0"> version = "2.0">
<BuildAction <BuildAction
...@@ -84,6 +84,11 @@ ...@@ -84,6 +84,11 @@
isEnabled = "YES"> isEnabled = "YES">
</EnvironmentVariable> </EnvironmentVariable>
<EnvironmentVariable <EnvironmentVariable
key = "CG_NUMERICS_SHOW_BACKTRACE"
value = ""
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView" key = "_XCWidgetDefaultView"
value = "timeline" value = "timeline"
isEnabled = "NO"> isEnabled = "NO">
......
//
// AdCacheManager.swift
// BaconReader
//
// Created by Steve Pint on 4/24/19.
// Copyright © 2019 OneLouder Apps. All rights reserved.
//
import UIKit
import FBAudienceNetwork
import OneWeatherCore
import OneWeatherAnalytics
class AdCacheManager: NSObject {
@objc
static let shared = AdCacheManager()
@objc
enum AdPlacementKey: Int {
case sidebarBanner
case stickyBannerList
case stickyBannerDetail
var name: String {
switch self {
case .sidebarBanner: return "sidebar_banner"
case .stickyBannerList: return "sticky_banner_list"
case .stickyBannerDetail: return "sticky_banner_detail"
}
}
}
private var sidebarBannerViews = [AdView]()
private var stickyAdViewList: AdView?
private var stickyAdViewDetail: AdView?
private let log = AdLogger(componentName: "AdViewCache")
internal override init() {
super.init()
// No need to handle config change notification, because AdViews and Interstitials will take care of reinitializing themselves.
NotificationCenter.default.addObserver(self, selector: #selector(changedToPro), name: NSNotification.Name(rawValue: kEventInAppPurchasedCompleted), object: nil)
// In Debug mode we allow the user to change the environment
#if DEBUG
EnvironmentManager.shared.environment = Settings.shared.adTestEnvironment ? .test : .production
if EnvironmentManager.shared.environment == Environment.test {
FBAdSettings.addTestDevice(FBAdSettings.testDeviceHash())
} else {
FBAdSettings.clearTestDevice(FBAdSettings.testDeviceHash())
}
#else
EnvironmentManager.shared.environment = .production
#endif
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// TODO: This needs to be moved to AdManager
@objc
var adsEnabled: Bool {
// TODO: see previous implementation. How should we handle this here?
return true
}
private func initAdCache() {
pushAd(adPlacement: .stickyBannerList, placement: AdPlacementKey.stickyBannerList.name)
pushAd(adPlacement: .stickyBannerDetail, placement: AdPlacementKey.stickyBannerDetail.name)
pushAd(adPlacement: .sidebarBanner, placement: AdPlacementKey.sidebarBanner.name)
}
var loggingAliasCounter = 1
private func pushAd(adPlacement: AdPlacementKey, placement: String) {
let adView = AdView()
adView.loggingAlias = "\(placement)-\(loggingAliasCounter)"
loggingAliasCounter += 1
adView.set(placementName: placement, adType: .banner)
adView.requestAd(startAutorefresh: false)
switch adPlacement {
case .sidebarBanner:
sidebarBannerViews.append(adView)
case .stickyBannerList:
stickyAdViewList = adView
case .stickyBannerDetail:
stickyAdViewDetail = adView
}
log.info("cache adView (\(placement))")
}
@objc
func popAd(adPlacement: AdPlacementKey) -> AdView? {
if AdConfigManager.shared.adConfig.placements != nil && AdConfigManager.shared.adConfig.placement(named: adPlacement.name) == nil {
log.error("placement not found in the config: \(adPlacement.name)")
}
switch adPlacement {
case .sidebarBanner:
pushAd(adPlacement: adPlacement, placement: adPlacement.name)
case .stickyBannerList:
//There is only one sticky banner! Do not create new
if stickyAdViewList == nil {
pushAd(adPlacement: adPlacement, placement: adPlacement.name)
}
case .stickyBannerDetail:
//There is only one sticky banner! Do not create new
if stickyAdViewDetail == nil {
pushAd(adPlacement: adPlacement, placement: adPlacement.name)
}
}
log.info("use adView (\(adPlacement.name))")
// add a new one for each one that gets removed
var result: AdView? = nil
switch adPlacement {
case .sidebarBanner:
result = sidebarBannerViews.removeFirst()
result?.start()
case .stickyBannerList:
result = stickyAdViewList
case .stickyBannerDetail:
result = stickyAdViewDetail
}
return result
}
/* //TODO :- Update this method when Interstitial is introduced.
@objc
func popInterstitialAd() -> Interstitial? {
guard let placement = AdConfigManager.shared.adConfig.placement(named: "brint") else {
if AdConfigManager.shared.adConfig.placements != nil {
// config is not empty, but the placement is missing
analytics(log: .kFlurryAdConfigPlacementNotFound)
}
return nil
}
log.info("use interstitial (\(placement.name))")
let loggingAlias = "\(placement.name)-\(loggingAliasCounter)"
loggingAliasCounter += 1
return Interstitial(placementName: placement.name, delegate: nil, loggingAlias: loggingAlias)
}
*/
@objc func setupAds() {
// Ads setup
if adsEnabled {
if let _ = AdConfigManager.shared.adConfig.placement(named: AdPlacementKey.sidebarBanner.name) {
clearCache()
initAdCache()
} else {
// analytics(log: .kFlurryAdConfigPlacementNotFound)
}
}
}
private func clearCache() {
sidebarBannerViews.removeAll()
}
// MARK: - notifications
@objc
func changedToPro(_ notification: Notification) {
NotificationCenter.default.removeObserver(self)
clearCache()
}
}
...@@ -7,14 +7,13 @@ ...@@ -7,14 +7,13 @@
// //
import UIKit import UIKit
import OneWeatherCore
import OneWeatherAnalytics import OneWeatherAnalytics
extension Notification.Name { extension Notification.Name {
public static let adConfigChanged = Notification.Name(rawValue: "AdConfig.Changed") public static let adConfigChanged = Notification.Name(rawValue: "AdConfig.Changed")
} }
public typealias AdPlacementName = String
public let placementNameTodayBanner: AdPlacementName = "today_banner" public let placementNameTodayBanner: AdPlacementName = "today_banner"
public let placementNameTodaySquare: AdPlacementName = "today_square" public let placementNameTodaySquare: AdPlacementName = "today_square"
public let placementNameForecastHourlyBanner: AdPlacementName = "forecast_hourly_banner" public let placementNameForecastHourlyBanner: AdPlacementName = "forecast_hourly_banner"
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import OneWeatherCore
import GoogleMobileAds import GoogleMobileAds
class NativeAdView: GADNativeAdView { class NativeAdView: GADNativeAdView {
......
...@@ -7,8 +7,9 @@ ...@@ -7,8 +7,9 @@
// //
import Foundation import Foundation
import GoogleMobileAds
import OneWeatherAnalytics import OneWeatherAnalytics
import OneWeatherCore
import GoogleMobileAds
protocol NativeBannerContainerViewDelegate: AnyObject { protocol NativeBannerContainerViewDelegate: AnyObject {
func adLoader(_ adLoader: GADAdLoader, didReceive nativeAd: GADNativeAd) func adLoader(_ adLoader: GADAdLoader, didReceive nativeAd: GADNativeAd)
......
...@@ -33,10 +33,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ...@@ -33,10 +33,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
private let appsFlyer = AppsFlyerLib.shared() private let appsFlyer = AppsFlyerLib.shared()
private let appsFlyerLog = Logger(componentName: "AppsFlyer") private let appsFlyerLog = Logger(componentName: "AppsFlyer")
private let log = Logger(componentName: "AppDelegate") private let log = Logger(componentName: "AppDelegate")
private let settings = Settings.shared
private let storeManager = StoreManager.shared
private enum FirstOpenSource: String {
case icon = "Icon"
case url = "Push Notification"
case pushNotification = "Share Deeplink"
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
settings.appDelegate = self
settings.delegate.add(delegate: self)
ThemeManager.refreshAppearance() ThemeManager.refreshAppearance()
UserDefaults.migrateUserDefaultsToAppGroupsIfNeeded() UserDefaults.migrateUserDefaultsToAppGroupsIfNeeded()
storeManager.onStartup()
if let launchOptions = launchOptions { if let launchOptions = launchOptions {
log.debug("Launch options: \(launchOptions)") log.debug("Launch options: \(launchOptions)")
...@@ -79,6 +91,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ...@@ -79,6 +91,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
FirebaseApp.configure() FirebaseApp.configure()
ConfigManager.shared.updateConfig() ConfigManager.shared.updateConfig()
FeatureAvailabilityManager.shared = configureFeatureAvailabilityManager(with: LocationManager.shared, configManager: ConfigManager.shared)
//App UI //App UI
let appCoordinator = AppCoordinator(window: self.window!, launchOptions: launchOptions) let appCoordinator = AppCoordinator(window: self.window!, launchOptions: launchOptions)
appCoordinator.start() appCoordinator.start()
...@@ -112,17 +126,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ...@@ -112,17 +126,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
MoEngage.sharedInstance().initializeLive(with: moEngageConfig, andLaunchOptions: launchOptions) MoEngage.sharedInstance().initializeLive(with: moEngageConfig, andLaunchOptions: launchOptions)
#endif #endif
if let widgetPromotionTriggerCount = Settings.shared.widgetPromotionTriggerCount { if let widgetPromotionTriggerCount = settings.widgetPromotionTriggerCount {
if widgetPromotionTriggerCount >= 0 { if widgetPromotionTriggerCount >= 0 {
Settings.shared.widgetPromotionTriggerCount = widgetPromotionTriggerCount - 1 settings.widgetPromotionTriggerCount = widgetPromotionTriggerCount - 1
} }
} }
else { else {
if CCPAHelper.shared.isNewUser { if CCPAHelper.shared.isNewUser {
Settings.shared.widgetPromotionTriggerCount = 2 settings.widgetPromotionTriggerCount = 2
} }
else { else {
Settings.shared.widgetPromotionTriggerCount = 0 settings.widgetPromotionTriggerCount = 0
} }
} }
return true return true
...@@ -152,6 +166,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ...@@ -152,6 +166,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidBecomeActive(_ application: UIApplication) { func applicationDidBecomeActive(_ application: UIApplication) {
LocationManager.shared.updateEverythingIfNeeded() LocationManager.shared.updateEverythingIfNeeded()
storeManager.verifyPurchases()
if #available(iOS 14, *) { if #available(iOS 14, *) {
WidgetManager.shared.refreshAnalytics() WidgetManager.shared.refreshAnalytics()
...@@ -161,7 +177,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ...@@ -161,7 +177,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
PushNotificationsManager.shared.set(pushToken: deviceToken) PushNotificationsManager.shared.set(pushToken: deviceToken)
} }
func initializeAppsFlyer() { func initializeAppsFlyer() {
appsFlyerLog.info("AppsFlyer initialize with AppsFlyerId: \(kAppsFlyerId), Apple App ID: \(kAppsFlyerAppId)") appsFlyerLog.info("AppsFlyer initialize with AppsFlyerId: \(kAppsFlyerId), Apple App ID: \(kAppsFlyerAppId)")
appsFlyer.appsFlyerDevKey = kAppsFlyerId appsFlyer.appsFlyerDevKey = kAppsFlyerId
...@@ -182,6 +198,71 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ...@@ -182,6 +198,71 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
} }
} }
} }
private func configureFeatureAvailabilityManager(with locationManager: LocationManager, configManager: ConfigManager) -> FeatureAvailabilityManager {
let usOnly = USOnlyFeatureAvailabilityChecker { [weak locationManager] in
return locationManager?.selectedLocation
}
let premium = ClosureFeatureAvailabilityChecker { [weak self] in
guard let storeManager = self?.storeManager else {
return false
}
return storeManager.removeAdsPurchased
}
let activeProSubscription = ClosureFeatureAvailabilityChecker { [weak self] in
guard let storeManager = self?.storeManager else {
return false
}
return storeManager.hasSubscription
}
let everSubscribed = ClosureFeatureAvailabilityChecker { [weak self] in
guard let storeManager = self?.storeManager else {
return false
}
return storeManager.everHadSubscription
}
let isPhone = DeviceTypeFeatureAvailabilityChecker(deviceType: .phone)
var availabilityCheckers = [AppFeature: FeatureAvailabilityChecker]()
for feature in AppFeature.allCases {
// To make sure all features are explicitly configured.
switch feature {
case .ads:
availabilityCheckers[feature] = !premium && !activeProSubscription && !everSubscribed
case .minutelyForecast:
availabilityCheckers[feature] = activeProSubscription
case .shorts:
availabilityCheckers[feature] = usOnly && isPhone
case .airQualityIndex:
availabilityCheckers[feature] = activeProSubscription
case .attPrompt:
break // config only
case .nwsAlertsViaMoEngage:
break // config only
case .onboarding:
break // config only
case .shortsLastNudge:
break // config only
case .subscription:
break // configOnly
case .subscriptionForPro:
availabilityCheckers[feature] = premium
case .extendedDailyForecast:
availabilityCheckers[feature] = activeProSubscription
case .extendedHourlyForecast:
availabilityCheckers[feature] = activeProSubscription
}
}
let manager = FeatureAvailabilityManager(configFetcher: { [weak configManager] in
configManager?.config
},
checkers: availabilityCheckers)
return manager
}
} }
extension AppDelegate: AppsFlyerLibDelegate { extension AppDelegate: AppsFlyerLibDelegate {
...@@ -244,3 +325,35 @@ extension AppDelegate: DeepLinkDelegate { ...@@ -244,3 +325,35 @@ extension AppDelegate: DeepLinkDelegate {
router.open(url: url) router.open(url: url)
} }
} }
extension AppDelegate: SettingsDelegate {
func settingsDidChange() {
DispatchQueue.main.async {
// accessing appTheme right away causes a crash, because settingsDidChange is posted right after it was modified
if #available(iOS 13, *) {
switch self.settings.appTheme {
case .light:
UIApplication.shared.keyWindow?.overrideUserInterfaceStyle = .light
case .dark:
UIApplication.shared.keyWindow?.overrideUserInterfaceStyle = .dark
case .system:
UIApplication.shared.keyWindow?.overrideUserInterfaceStyle = .unspecified
}
}
}
}
}
extension AppDelegate: SettingsAppDelegate {
func openDeviceSettings() {
guard
let bundleIdentifier = Bundle.main.bundleIdentifier,
let appSettingsURL = URL(string: UIApplication.openSettingsURLString + bundleIdentifier)
else {
assert(false, "Failed to create settings URL from: \(UIApplication.openSettingsURLString)")
return
}
UIApplication.shared.open(appSettingsURL)
}
}
//
// LocalizationChangeObserver.swift
// 1Weather
//
// Created by Demid Merzlyakov on 10.09.2021.
//
import Foundation
public class LocalizationChangeObserver: NSObject {
private let handler: () -> ()
private var notificationCenter: NotificationCenter {
NotificationCenter.default
}
public init(handler: @escaping () -> ()) {
self.handler = handler
super.init()
notificationCenter.addObserver(self, selector: #selector(notificationHandler), name: .localizationChange, object: nil)
}
deinit {
notificationCenter.removeObserver(self)
}
@objc
private func notificationHandler() {
handler()
}
}
...@@ -2,6 +2,22 @@ ...@@ -2,6 +2,22 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>ios_subscription_config</key>
<string>{
&quot;subscriptions_enabled&quot;: true,
&quot;monthly&quot;: {
&quot;product_id&quot;: &quot;com.onelouder.oneweather.subscription.premium&quot;
},
&quot;yearly&quot;: {
&quot;product_id&quot;: &quot;com.onelouder.oneweather.subscription.premium.yearly&quot;
},
&quot;monthly_discounted&quot;: {
&quot;product_id&quot;: &quot;com.onelouder.oneweather.subscription.forPro.premium&quot;
},
&quot;yearly_discounted&quot;: {
&quot;product_id&quot;: &quot;com.onelouder.oneweather.subscription.pro.premium.yearly&quot;
}
}</string>
<key>search_screen_popular_cities</key> <key>search_screen_popular_cities</key>
<string>[{&quot;lat&quot;:&quot;40.7127&quot;,&quot;lon&quot;:&quot;-74.006&quot;,&quot;city&quot;:&quot;New York&quot;,&quot;state&quot;:&quot;New York&quot;,&quot;stateCode&quot;:&quot;NY&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:1},{&quot;lat&quot;:&quot;41.8756&quot;,&quot;lon&quot;:&quot;-87.6244&quot;,&quot;city&quot;:&quot;Chicago&quot;,&quot;state&quot;:&quot;Illinois&quot;,&quot;stateCode&quot;:&quot;IL&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:2},{&quot;lat&quot;:&quot;29.7589&quot;,&quot;lon&quot;:&quot;-95.3677&quot;,&quot;city&quot;:&quot;Houston&quot;,&quot;state&quot;:&quot;Texas&quot;,&quot;stateCode&quot;:&quot;TX&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:3},{&quot;lat&quot;:&quot;34.0537&quot;,&quot;lon&quot;:&quot;-118.243&quot;,&quot;city&quot;:&quot;Los Angeles&quot;,&quot;state&quot;:&quot;California&quot;,&quot;stateCode&quot;:&quot;CA&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:4},{&quot;lat&quot;:&quot;29.4246&quot;,&quot;lon&quot;:&quot;-98.4951&quot;,&quot;city&quot;:&quot;San Antonio&quot;,&quot;state&quot;:&quot;Texas&quot;,&quot;stateCode&quot;:&quot;TX&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:5},{&quot;lat&quot;:&quot;39.7683&quot;,&quot;lon&quot;:&quot;-86.1584&quot;,&quot;city&quot;:&quot;Indianapolis&quot;,&quot;state&quot;:&quot;Indiana&quot;,&quot;stateCode&quot;:&quot;IN&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:6},{&quot;lat&quot;:&quot;39.9527&quot;,&quot;lon&quot;:&quot;-75.1635&quot;,&quot;city&quot;:&quot;Philadelphia&quot;,&quot;state&quot;:&quot;Pennsylvania&quot;,&quot;stateCode&quot;:&quot;PA&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:7},{&quot;lat&quot;:&quot;36.1673&quot;,&quot;lon&quot;:&quot;-115.149&quot;,&quot;city&quot;:&quot;Las Vegas&quot;,&quot;state&quot;:&quot;Nevada&quot;,&quot;stateCode&quot;:&quot;NV&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:8},{&quot;lat&quot;:&quot;39.9623&quot;,&quot;lon&quot;:&quot;-83.0007&quot;,&quot;city&quot;:&quot;Columbus&quot;,&quot;state&quot;:&quot;Ohio&quot;,&quot;stateCode&quot;:&quot;OH&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:9},{&quot;lat&quot;:&quot;30.3322&quot;,&quot;lon&quot;:&quot;-81.6557&quot;,&quot;city&quot;:&quot;Jacksonville&quot;,&quot;state&quot;:&quot;Florida&quot;,&quot;stateCode&quot;:&quot;FL&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:10}]</string> <string>[{&quot;lat&quot;:&quot;40.7127&quot;,&quot;lon&quot;:&quot;-74.006&quot;,&quot;city&quot;:&quot;New York&quot;,&quot;state&quot;:&quot;New York&quot;,&quot;stateCode&quot;:&quot;NY&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:1},{&quot;lat&quot;:&quot;41.8756&quot;,&quot;lon&quot;:&quot;-87.6244&quot;,&quot;city&quot;:&quot;Chicago&quot;,&quot;state&quot;:&quot;Illinois&quot;,&quot;stateCode&quot;:&quot;IL&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:2},{&quot;lat&quot;:&quot;29.7589&quot;,&quot;lon&quot;:&quot;-95.3677&quot;,&quot;city&quot;:&quot;Houston&quot;,&quot;state&quot;:&quot;Texas&quot;,&quot;stateCode&quot;:&quot;TX&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:3},{&quot;lat&quot;:&quot;34.0537&quot;,&quot;lon&quot;:&quot;-118.243&quot;,&quot;city&quot;:&quot;Los Angeles&quot;,&quot;state&quot;:&quot;California&quot;,&quot;stateCode&quot;:&quot;CA&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:4},{&quot;lat&quot;:&quot;29.4246&quot;,&quot;lon&quot;:&quot;-98.4951&quot;,&quot;city&quot;:&quot;San Antonio&quot;,&quot;state&quot;:&quot;Texas&quot;,&quot;stateCode&quot;:&quot;TX&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:5},{&quot;lat&quot;:&quot;39.7683&quot;,&quot;lon&quot;:&quot;-86.1584&quot;,&quot;city&quot;:&quot;Indianapolis&quot;,&quot;state&quot;:&quot;Indiana&quot;,&quot;stateCode&quot;:&quot;IN&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:6},{&quot;lat&quot;:&quot;39.9527&quot;,&quot;lon&quot;:&quot;-75.1635&quot;,&quot;city&quot;:&quot;Philadelphia&quot;,&quot;state&quot;:&quot;Pennsylvania&quot;,&quot;stateCode&quot;:&quot;PA&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:7},{&quot;lat&quot;:&quot;36.1673&quot;,&quot;lon&quot;:&quot;-115.149&quot;,&quot;city&quot;:&quot;Las Vegas&quot;,&quot;state&quot;:&quot;Nevada&quot;,&quot;stateCode&quot;:&quot;NV&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:8},{&quot;lat&quot;:&quot;39.9623&quot;,&quot;lon&quot;:&quot;-83.0007&quot;,&quot;city&quot;:&quot;Columbus&quot;,&quot;state&quot;:&quot;Ohio&quot;,&quot;stateCode&quot;:&quot;OH&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:9},{&quot;lat&quot;:&quot;30.3322&quot;,&quot;lon&quot;:&quot;-81.6557&quot;,&quot;city&quot;:&quot;Jacksonville&quot;,&quot;state&quot;:&quot;Florida&quot;,&quot;stateCode&quot;:&quot;FL&quot;,&quot;country&quot;:&quot;United States of America&quot;,&quot;countryCode&quot;:&quot;US&quot;,&quot;order&quot;:10}]</string>
<key>ios_nws_alerts_via_moengage_enabled</key> <key>ios_nws_alerts_via_moengage_enabled</key>
......
...@@ -26,6 +26,9 @@ class AppCoordinator: Coordinator { ...@@ -26,6 +26,9 @@ class AppCoordinator: Coordinator {
var parentCoordinator: Coordinator? var parentCoordinator: Coordinator?
var childCoordinators = [Coordinator]() var childCoordinators = [Coordinator]()
private var featureAvailabilityManager: FeatureAvailabilityManager {
FeatureAvailabilityManager.shared
}
init(window:UIWindow, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { init(window:UIWindow, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
self.window = window self.window = window
...@@ -109,7 +112,7 @@ class AppCoordinator: Coordinator { ...@@ -109,7 +112,7 @@ class AppCoordinator: Coordinator {
DispatchQueue.main.async { DispatchQueue.main.async {
self.logAppLaunchEvents() self.logAppLaunchEvents()
if ConfigManager.shared.config.showOnboarding { if self.featureAvailabilityManager.isAvailable(feature: .onboarding) {
if Settings.shared.initialOnboardingShowed { if Settings.shared.initialOnboardingShowed {
self.finishInitialOnboarding() self.finishInitialOnboarding()
} }
...@@ -158,7 +161,7 @@ class AppCoordinator: Coordinator { ...@@ -158,7 +161,7 @@ class AppCoordinator: Coordinator {
firstOpenSource = .url firstOpenSource = .url
} }
let flow = ConfigManager.shared.config.showOnboarding ? "FTUX" : "default" let flow = featureAvailabilityManager.isAvailable(feature: .onboarding) ? "FTUX" : "default"
analytics(log: .ANALYTICS_FIRST_OPEN, analytics(log: .ANALYTICS_FIRST_OPEN,
params: [.ANALYTICS_KEY_FIRST_OPEN_SOURCE : firstOpenSource.rawValue, params: [.ANALYTICS_KEY_FIRST_OPEN_SOURCE : firstOpenSource.rawValue,
.ANALYTICS_KEY_FIRST_OPEN_FLOW : flow]) .ANALYTICS_KEY_FIRST_OPEN_FLOW : flow])
......
...@@ -37,4 +37,18 @@ class MenuCoordinator: Coordinator { ...@@ -37,4 +37,18 @@ class MenuCoordinator: Coordinator {
childCoordinators.append(settingsCoordinator) childCoordinators.append(settingsCoordinator)
settingsCoordinator.start() settingsCoordinator.start()
} }
func openSubscriptions() {
let subscriptionsCoordinator = SubscriptionCoordinator(parentViewController: self.navigationController)
subscriptionsCoordinator.parentCoordinator = self
childCoordinators.append(subscriptionsCoordinator)
subscriptionsCoordinator.start()
}
public func openSubscriptionOverview() {
let overviewCoordinator = SubscriptionOverviewCoordinator(parentViewController: self.navigationController)
overviewCoordinator.parentCoordinator = self
childCoordinators.append(overviewCoordinator)
overviewCoordinator.start()
}
} }
//
// SubscriptionCoordinator.swift
// 1Weather
//
// Created by Demid Merzlyakov on 10.09.2021.
//
import UIKit
import OneWeatherCore
class SubscriptionCoordinator: Coordinator {
private let parentViewController: UIViewController
public var childCoordinators = [Coordinator]()
public var parentCoordinator: Coordinator?
private let storeManager: StoreManager
public init(parentViewController: UIViewController, storeManager: StoreManager = StoreManager.shared) {
self.parentViewController = parentViewController
self.storeManager = storeManager
}
public func start() {
let viewModel = SubscriptionViewModel(storeManager: storeManager)
let vc = SubscriptionStoreViewController(coordinator: self, viewModel: viewModel)
self.parentViewController.present(vc, animated: true)
}
public func openOverview() {
let overviewCoordinator = SubscriptionOverviewCoordinator(parentViewController: self.parentViewController)
overviewCoordinator.parentCoordinator = self
childCoordinators.append(overviewCoordinator)
overviewCoordinator.start()
}
func viewControllerDidEnd(controller: UIViewController) {
parentCoordinator?.childDidFinish(child: self)
}
}
//
// SubscriptionOverviewCoordinator.swift
// 1Weather
//
// Created by Demid Merzlyakov on 12.09.2021.
//
import UIKit
import OneWeatherCore
class SubscriptionOverviewCoordinator: Coordinator {
private let parentViewController: UIViewController
public var childCoordinators = [Coordinator]()
public var parentCoordinator: Coordinator?
public init(parentViewController: UIViewController) {
self.parentViewController = parentViewController
}
func start() {
let vc = SubscriptionOverviewViewController(coordinator: self)
self.parentViewController.present(vc, animated: true)
}
func viewControllerDidEnd(controller: UIViewController) {
parentCoordinator?.childDidFinish(child: self)
}
}
{
"identifier" : "A7053B34",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "1.99",
"familyShareable" : false,
"internalID" : "22A7BDE0",
"localizations" : [
{
"description" : "",
"displayName" : "Upgrade to 1Weather Pro",
"locale" : "en_US"
}
],
"productID" : "com.onelouder.oneweather.inapp1",
"referenceName" : "1Weather Pro ",
"type" : "NonConsumable"
}
],
"settings" : {
"_timeRate" : 6
},
"subscriptionGroups" : [
{
"id" : "AC6BEB61",
"localizations" : [
],
"name" : "Premium Subscription",
"subscriptions" : [
{
"adHocOffers" : [
],
"displayPrice" : "1.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "B3859330",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "Test Description 1W Monthly",
"displayName" : "1Weather Premium Monthly",
"locale" : "en_US"
}
],
"productID" : "com.onelouder.oneweather.subscription.premium",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Premium Subscription Monthly",
"subscriptionGroupID" : "AC6BEB61",
"type" : "RecurringSubscription"
},
{
"adHocOffers" : [
],
"displayPrice" : "9.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "47B4A0A3",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "Test Description 1W Yearly",
"displayName" : "1Weather Premium Yearly",
"locale" : "en_US"
}
],
"productID" : "com.onelouder.oneweather.subscription.premium.yearly",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "Premium Subscription Yearly",
"subscriptionGroupID" : "AC6BEB61",
"type" : "RecurringSubscription"
}
]
},
{
"id" : "4BDB84FF",
"localizations" : [
],
"name" : "Premium Subscription (for Pro)",
"subscriptions" : [
{
"adHocOffers" : [
],
"displayPrice" : "1.49",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "9E811EB0",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "",
"displayName" : "1Weather Premium Monthly (Pro)",
"locale" : "en_US"
}
],
"productID" : "com.onelouder.oneweather.subscription.forPro.premium",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Premium Subscription Monthly (for Pro)",
"subscriptionGroupID" : "4BDB84FF",
"type" : "RecurringSubscription"
},
{
"adHocOffers" : [
],
"displayPrice" : "6.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "0EE8019B",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "",
"displayName" : "1Weather Premium Yearly (Pro)",
"locale" : "en_US"
}
],
"productID" : "com.onelouder.oneweather.subscription.pro.premium.yearly",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "Premium Subscription Yearly (for Pro)",
"subscriptionGroupID" : "4BDB84FF",
"type" : "RecurringSubscription"
}
]
}
],
"version" : {
"major" : 1,
"minor" : 1
}
}
...@@ -12,39 +12,6 @@ import FirebaseRemoteConfig ...@@ -12,39 +12,6 @@ import FirebaseRemoteConfig
import OneWeatherAnalytics import OneWeatherAnalytics
import OneWeatherCore import OneWeatherCore
public struct AppConfig: Codable {
public let popularCities: [GeoNamesPlace]?
public let adConfig: AdConfig
public let ccpaUpdateInterval: TimeInterval?
public let nwsAlertsViaMoEngageEnabled: Bool
public let showAttPrompt: Bool
public let shortsLeftBelowCount: Int
public let shortsLastNudgeEnabled: Bool
public let shortsSwipeUpNudgeCount: Int
public let showOnboarding: Bool
public init(popularCities: [GeoNamesPlace]?,
adConfig: AdConfig,
ccpaUpdateInterval: TimeInterval?,
nwsAlertsViaMoEngageEnabled: Bool,
showAttPrompt: Bool,
shortsLeftBelowCountKey: Int,
shortsLastNudgeEnabledKey: Bool,
shortsSwipeUpNudgeCountKey: Int,
showOnboarding: Bool
) {
self.popularCities = popularCities
self.adConfig = adConfig
self.ccpaUpdateInterval = ccpaUpdateInterval
self.nwsAlertsViaMoEngageEnabled = nwsAlertsViaMoEngageEnabled
self.showAttPrompt = showAttPrompt
self.shortsLeftBelowCount = shortsLeftBelowCountKey
self.shortsLastNudgeEnabled = shortsLastNudgeEnabledKey
self.shortsSwipeUpNudgeCount = shortsSwipeUpNudgeCountKey
self.showOnboarding = showOnboarding
}
}
public protocol ConfigManagerDelegate { public protocol ConfigManagerDelegate {
func dataUpdated(by configManager: ConfigManager) func dataUpdated(by configManager: ConfigManager)
} }
...@@ -53,12 +20,9 @@ public class ConfigManager { ...@@ -53,12 +20,9 @@ public class ConfigManager {
private static let adConfigKey = "ads_config_ios" private static let adConfigKey = "ads_config_ios"
private static let popularCitiesConfigKey = "search_screen_popular_cities" private static let popularCitiesConfigKey = "search_screen_popular_cities"
private static let ccpaUpdateIntervalConfigKey = "ccpa_update_interval_ios" private static let ccpaUpdateIntervalConfigKey = "ccpa_update_interval_ios"
private static let nwsAlertsViaMoEngageEnabledKey = "ios_nws_alerts_via_moengage_enabled"
private static let showAttPromptKey = "ios_show_att_prompt"
private static let shortsLeftBelowCountKey = "shorts_left_below_nudge_every_x_cards" private static let shortsLeftBelowCountKey = "shorts_left_below_nudge_every_x_cards"
private static let shortsLastNudgeEnabledKey = "shorts_swipe_down_nudge_enabled"
private static let shortsSwipeUpNudgeCountKey = "shorts_swipe_up_nudge_on_x_cards" private static let shortsSwipeUpNudgeCountKey = "shorts_swipe_up_nudge_on_x_cards"
private static let showOnboardingKey = "ios_show_onboarding" private static let subscriptionConfigKey = "ios_subscription_config"
private let delegates = MulticastDelegate<ConfigManagerDelegate>() private let delegates = MulticastDelegate<ConfigManagerDelegate>()
...@@ -77,12 +41,15 @@ public class ConfigManager { ...@@ -77,12 +41,15 @@ public class ConfigManager {
public var config: AppConfig = AppConfig(popularCities: nil, public var config: AppConfig = AppConfig(popularCities: nil,
adConfig: AdConfig(), adConfig: AdConfig(),
ccpaUpdateInterval: nil, ccpaUpdateInterval: nil,
nwsAlertsViaMoEngageEnabled: true,
showAttPrompt: false,
shortsLeftBelowCountKey: 0, shortsLeftBelowCountKey: 0,
shortsLastNudgeEnabledKey: false,
shortsSwipeUpNudgeCountKey: 0, shortsSwipeUpNudgeCountKey: 0,
showOnboarding: false) explicitFeatureAvailability: [
.nwsAlertsViaMoEngage: true,
.attPrompt: false,
.shortsLastNudge: false,
.onboarding: false
], subscriptionsConfig: SubscriptionsListConfig()
)
public func updateConfig() { public func updateConfig() {
log.info("update config") log.info("update config")
...@@ -120,13 +87,24 @@ public class ConfigManager { ...@@ -120,13 +87,24 @@ public class ConfigManager {
delegates.remove(delegate: delegate) delegates.remove(delegate: delegate)
} }
private func parseFeatureAvailability() -> [AppFeature: Bool] {
var featureAvailability = [AppFeature: Bool]()
for feature in AppFeature.allCases {
if let configName = feature.configVariableName {
let configValue = remoteConfig.configValue(forKey: configName)
featureAvailability[feature] = configValue.boolValue
}
}
return featureAvailability
}
private func parseConfigFromFirebase(source: String) { private func parseConfigFromFirebase(source: String) {
log.info("Got config from \(source)") log.info("Got config from \(source)")
var configErrors = [Error]() var configErrors = [Error]()
let decoder = JSONDecoder()
var adConfig = AdConfig() var adConfig = AdConfig()
do { do {
let decoder = JSONDecoder()
let adConfigData = remoteConfig.configValue(forKey: ConfigManager.adConfigKey).dataValue let adConfigData = remoteConfig.configValue(forKey: ConfigManager.adConfigKey).dataValue
adConfig = try decoder.decode(AdConfig.self, from: adConfigData) adConfig = try decoder.decode(AdConfig.self, from: adConfigData)
} }
...@@ -136,6 +114,7 @@ public class ConfigManager { ...@@ -136,6 +114,7 @@ public class ConfigManager {
var popularCities: [GeoNamesPlace]? = nil var popularCities: [GeoNamesPlace]? = nil
do { do {
let decoder = JSONDecoder()
let popularCitiesData = remoteConfig.configValue(forKey: ConfigManager.popularCitiesConfigKey).dataValue let popularCitiesData = remoteConfig.configValue(forKey: ConfigManager.popularCitiesConfigKey).dataValue
popularCities = try decoder.decode([GeoNamesPlace].self, from: popularCitiesData) popularCities = try decoder.decode([GeoNamesPlace].self, from: popularCitiesData)
} }
...@@ -156,34 +135,34 @@ public class ConfigManager { ...@@ -156,34 +135,34 @@ public class ConfigManager {
if ccpaUpdateIntervalConfigValue.source == RemoteConfigSource.remote { if ccpaUpdateIntervalConfigValue.source == RemoteConfigSource.remote {
ccpaUpdateInterval = ccpaUpdateIntervalConfigValue.numberValue.doubleValue ccpaUpdateInterval = ccpaUpdateIntervalConfigValue.numberValue.doubleValue
} }
let nwsAlertsViaMoEngageEnabledValue = remoteConfig.configValue(forKey: ConfigManager.nwsAlertsViaMoEngageEnabledKey)
let nwsAlertsViaMoEngageEnabled = nwsAlertsViaMoEngageEnabledValue.boolValue
let showAttPromptValue = remoteConfig.configValue(forKey: ConfigManager.showAttPromptKey)
let showAttPrompt = showAttPromptValue.boolValue
let shortsLeftBelowCountValue = remoteConfig.configValue(forKey: ConfigManager.shortsLeftBelowCountKey) let shortsLeftBelowCountValue = remoteConfig.configValue(forKey: ConfigManager.shortsLeftBelowCountKey)
let shortsLeftBelowCount = shortsLeftBelowCountValue.numberValue.intValue let shortsLeftBelowCount = shortsLeftBelowCountValue.numberValue.intValue
let shortsLastNudgeEnabledValue = remoteConfig.configValue(forKey: ConfigManager.shortsLastNudgeEnabledKey)
let shortsLastNudgeEnabled = shortsLastNudgeEnabledValue.boolValue
let shortsSwipeNudgeCountValue = remoteConfig.configValue(forKey: ConfigManager.shortsSwipeUpNudgeCountKey) let shortsSwipeNudgeCountValue = remoteConfig.configValue(forKey: ConfigManager.shortsSwipeUpNudgeCountKey)
let shortsSwipeNudgeCount = shortsSwipeNudgeCountValue.numberValue.intValue let shortsSwipeNudgeCount = shortsSwipeNudgeCountValue.numberValue.intValue
let showOnboardingValue = remoteConfig.configValue(forKey: ConfigManager.showOnboardingKey) let featureAvailability = parseFeatureAvailability()
let showOnboarding = showOnboardingValue.boolValue
var subscriptionsConfig = SubscriptionsListConfig()
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let subscriptionConfigData = remoteConfig.configValue(forKey: ConfigManager.subscriptionConfigKey).dataValue
subscriptionsConfig = try decoder.decode(SubscriptionsListConfig.self, from: subscriptionConfigData)
}
catch (let error) {
configErrors.append(error)
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.config = AppConfig(popularCities: popularCities, self.config = AppConfig(popularCities: popularCities,
adConfig: adConfig, adConfig: adConfig,
ccpaUpdateInterval:ccpaUpdateInterval, ccpaUpdateInterval:ccpaUpdateInterval,
nwsAlertsViaMoEngageEnabled: nwsAlertsViaMoEngageEnabled,
showAttPrompt: showAttPrompt,
shortsLeftBelowCountKey: shortsLeftBelowCount, shortsLeftBelowCountKey: shortsLeftBelowCount,
shortsLastNudgeEnabledKey: shortsLastNudgeEnabled,
shortsSwipeUpNudgeCountKey: shortsSwipeNudgeCount, shortsSwipeUpNudgeCountKey: shortsSwipeNudgeCount,
showOnboarding: showOnboarding) explicitFeatureAvailability: featureAvailability,
subscriptionsConfig: subscriptionsConfig
)
self.notifyAboutConfigUpdate() self.notifyAboutConfigUpdate()
DispatchQueue.global().async { DispatchQueue.global().async {
let encoder = JSONEncoder() let encoder = JSONEncoder()
...@@ -204,3 +183,21 @@ public class ConfigManager { ...@@ -204,3 +183,21 @@ public class ConfigManager {
} }
} }
} }
fileprivate extension AppFeature {
var configVariableName: String? {
switch self {
case .nwsAlertsViaMoEngage:
return "ios_nws_alerts_via_moengage_enabled"
case .attPrompt:
return "ios_show_att_prompt"
case .shortsLastNudge:
return "shorts_swipe_down_nudge_enabled"
case .onboarding:
return "ios_show_onboarding"
case .ads, .airQualityIndex, .minutelyForecast, .shorts, .subscription, .subscriptionForPro, .extendedDailyForecast, .extendedHourlyForecast:
return nil
// don't use 'default', so that we didn't add new features here in the future.
}
}
}
...@@ -56,8 +56,12 @@ public class PushNotificationsManager: NSObject, PushNotificationsManagerProtoco ...@@ -56,8 +56,12 @@ public class PushNotificationsManager: NSObject, PushNotificationsManagerProtoco
internal var lastSetFIPSList: String? = "" internal var lastSetFIPSList: String? = ""
internal var lastSetFIPSCode: String? = "" internal var lastSetFIPSCode: String? = ""
private var featureAvailabilityManager: FeatureAvailabilityManager {
FeatureAvailabilityManager.shared
}
private func updateNwsSubscriptions(with fipsList: String?, currentFipsCode: String?) { private func updateNwsSubscriptions(with fipsList: String?, currentFipsCode: String?) {
if configManager.config.nwsAlertsViaMoEngageEnabled { if featureAvailabilityManager.isAvailable(feature: .nwsAlertsViaMoEngage) {
if fipsList != lastSetFIPSList { if fipsList != lastSetFIPSList {
log.info("Set \(AnalyticsAttribute.fipsList.attributeName) to '\(fipsList ?? "")'") log.info("Set \(AnalyticsAttribute.fipsList.attributeName) to '\(fipsList ?? "")'")
lastSetFIPSList = fipsList lastSetFIPSList = fipsList
......
...@@ -20,8 +20,12 @@ class ShortsManager { ...@@ -20,8 +20,12 @@ class ShortsManager {
static let shared = ShortsManager() static let shared = ShortsManager()
let multicastDelegate = MulticastDelegate<ShortsManagerDelegate>() let multicastDelegate = MulticastDelegate<ShortsManagerDelegate>()
private(set) var shorts = [ShortsItem]() private(set) var shorts = [ShortsItem]()
private var featureAvailabilityManager: FeatureAvailabilityManager {
FeatureAvailabilityManager.shared
}
var shortsAvailable: Bool { var shortsAvailable: Bool {
return LocationManager.shared.selectedLocation?.countryCode == "US" && UIDevice.current.userInterfaceIdiom == .phone return featureAvailabilityManager.isAvailable(feature: .shorts)
} }
//Private //Private
......
{
"images" : [
{
"filename" : "menu_crown.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
{
"images" : [
{
"filename" : "menu_restore.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
{
"images" : [
{
"filename" : "group.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
{
"images" : [
{
"filename" : "subscription_cross.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
{
"images" : [
{
"filename" : "subscription_header_close.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"images" : [
{
"filename" : "1WeatherPremium.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
{
"images" : [
{
"filename" : "Group 2.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
{
"images" : [
{
"filename" : "Star.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
...@@ -5,9 +5,9 @@ ...@@ -5,9 +5,9 @@
"color-space" : "srgb", "color-space" : "srgb",
"components" : { "components" : {
"alpha" : "1.000", "alpha" : "1.000",
"blue" : "243", "blue" : "0xF3",
"green" : "103", "green" : "0x67",
"red" : "31" "red" : "0x1F"
} }
}, },
"idiom" : "universal" "idiom" : "universal"
......
{
"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
}
}
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.922",
"green" : "0.399",
"red" : "0.205"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.922",
"green" : "0.399",
"red" : "0.205"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.780",
"green" : "0.940",
"red" : "0.991"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.780",
"green" : "0.940",
"red" : "0.991"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.329",
"green" : "0.795",
"red" : "0.966"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.329",
"green" : "0.795",
"red" : "0.966"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
...@@ -192,12 +192,17 @@ ...@@ -192,12 +192,17 @@
//Menu //Menu
"menu.goPremium" = "Go premium"; "menu.goPremium" = "Go Premium";
"menu.premium.desc" = "Experience app without ads"; "menu.upgradeTo" = "Upgrade to";
"menu.premiumMembership" = "Premium\nMembership";
"menu.premium.desc" = "Subcribe to 1Weather premium for a unique experience.";
"menu.tv" = "1Weather TV"; "menu.tv" = "1Weather TV";
"menu.radar" = "Radar"; "menu.radar" = "Radar";
"menu.buyNow" = "Buy now"; "menu.buyNow" = "Buy now";
"menu.upgradeNow" = "Upgrade now";
"menu.settings" = "Settings"; "menu.settings" = "Settings";
"menu.subscriptionOverview" = "Premium";
"menu.restorePurchases" = "Restore purchases";
"menu.about" = "About us"; "menu.about" = "About us";
"menu.ad" = "Ad choices"; "menu.ad" = "Ad choices";
"menu.rateUs" = "Rate us"; "menu.rateUs" = "Rate us";
...@@ -212,6 +217,46 @@ ...@@ -212,6 +217,46 @@
"menu.deviceId.text" = "Your device identifier is: "; "menu.deviceId.text" = "Your device identifier is: ";
"menu.help.unableToSendEmail.text" = "Device is unable to send email."; "menu.help.unableToSendEmail.text" = "Device is unable to send email.";
//Subscriptions
"subscription.restore.failed" = " Failed to restore purchases";
"subscription.restore.success" = "All purchases restored";
"subscription.restore.nothing" = "Nothing to restore";
"subscription.header.oneWeather" = "1Weather";
"subscription.header.premium" = "PREMIUM";
"subscription.proMember.already.top" = "You are a Pro member currently";
"subscription.proMember.already.bottom" = "Ad free experience";
"subscription.description.upgradePremium" = "Upgrade to Premium";
"subscription.description.forPro" = "For Pro users";
"subscription.description.discount" = "%@ OFF";
"subscription.description.header" = "Get the best of 1 Weather";
"subscription.description.header.purchased" = "Thank you for subscribing to 1Weather Premium";
"subscription.description.items.youGet" = "You get:";
"subscription.description.items.adFree" = "Ad free experience";
"subscription.description.items.adFree.forever" = "Forever";
"subscription.description.items.hourly" = " 48 hours of Hourly forecasts";
"subscription.description.items.hourly.pro" = "Hourly forecasts";
"subscription.description.items.hourly.crossed" = "24 hours";
"subscription.description.items.hourly.pro.opt" = "12 hours";
"subscription.description.items.hourly.premium.opt" = "48 hours";
"subscription.description.items.daily" = " 10 Days of Daily forecasts";
"subscription.description.items.daily.pro" = "Daily forecasts";
"subscription.description.items.daily.crossed" = "7 days";
"subscription.description.items.daily.pro.opt" = "7 days";
"subscription.description.items.daily.premium.opt" = "10 days";
"subscription.description.items.aqi" = "AQI card";
"subscription.description.items.comingSoon" = "Coming Soon";
"subscription.description.items.minutely" = "Minutely Forecast";
"subscription.description.cancellation" = "In order to cancel subscription, please open App Store > Account > Subscriptions and select 1Weather.";
"subscription.button.buy.yearly" = "Subscribe yearly for #PRICE#";
"subscription.button.buy.monthly" = "Subscribe monthly for #PRICE#";
"subscription.button.upgrade.yearly" = "Upgrade for #PRICE# #DISCOUNT_PRICE# / year";
"subscription.button.upgrade.monthly" = "Upgrade for #PRICE# #DISCOUNT_PRICE# / month";
//Settings //Settings
"settings.theme.automatic" = "Automatic"; "settings.theme.automatic" = "Automatic";
"settings.theme.automaticDesc" = "Enable light or dark theme based on your device brightness and display settings"; "settings.theme.automaticDesc" = "Enable light or dark theme based on your device brightness and display settings";
...@@ -306,8 +351,8 @@ ...@@ -306,8 +351,8 @@
"onboarding.skip" = "Skip"; "onboarding.skip" = "Skip";
"onboarding.done" = "Done"; "onboarding.done" = "Done";
"onboarding.forecast.title" = "Extended forecast"; "onboarding.forecast.title" = "Extended forecast";
"onboarding.forecast.subtitle" = "<Description>"; "onboarding.forecast.subtitle" = "Get accurate forecasts for extended periods of time for any location";
"onboarding.alerts.title" = "Weather alerts"; "onboarding.alerts.title" = "Weather alerts";
"onboarding.alerts.subtitle" = "<Description>"; "onboarding.alerts.subtitle" = "Get alerts about severe weather well-in-advance";
"onboarding.radar.title" = "Live doppler radar"; "onboarding.radar.title" = "Live doppler radar";
"onboarding.radar.subtitle" = "<Description>"; "onboarding.radar.subtitle" = "Our live radar gives you real-time weather updates with excellent accuracy";
...@@ -30,6 +30,27 @@ class ForecastTimePeriodControl: UISegmentedControl { ...@@ -30,6 +30,27 @@ class ForecastTimePeriodControl: UISegmentedControl {
updateUI() updateUI()
} }
public func set(items: [String]?) {
defer {
updateUI()
layoutSubviews()
}
guard
let itemsToAdd = items,
!itemsToAdd.isEmpty
else {
self.removeAllSegments()
return
}
self.removeAllSegments()
for (index, item) in itemsToAdd.enumerated() {
self.insertSegment(withTitle: item, at: index, animated: false)
}
selectedSegmentIndex = 0
}
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
......
...@@ -129,4 +129,13 @@ struct DefaultTheme: ThemeProtocol { ...@@ -129,4 +129,13 @@ struct DefaultTheme: ThemeProtocol {
var widgetPromotionText: UIColor { var widgetPromotionText: UIColor {
return UIColor(named: "widget_promotion_text") ?? .red return UIColor(named: "widget_promotion_text") ?? .red
} }
// Subscriptions
var subscriptionPurchaseBackgroundColor: UIColor {
return UIColor(named: "subscription_button_background") ?? .red
}
var subscriptionPurchaseColor: UIColor {
return UIColor(named: "subscription_button") ?? .red
}
} }
...@@ -56,4 +56,8 @@ public protocol ThemeProtocol { ...@@ -56,4 +56,8 @@ public protocol ThemeProtocol {
//Widget promotion //Widget promotion
var widgetPromotionBackground: UIColor { get } var widgetPromotionBackground: UIColor { get }
var widgetPromotionText: UIColor { get } var widgetPromotionText: UIColor { get }
// Subscriptions
var subscriptionPurchaseBackgroundColor: UIColor { get }
var subscriptionPurchaseColor: UIColor { get }
} }
...@@ -13,10 +13,10 @@ class PrecipitationCell: UITableViewCell { ...@@ -13,10 +13,10 @@ class PrecipitationCell: UITableViewCell {
private let headingLabel = UILabel() private let headingLabel = UILabel()
private let headingButton = ArrowButton() private let headingButton = ArrowButton()
private let minutelyForecastView = MinutelyForecastView() private let minutelyForecastView = MinutelyForecastView()
private let featureAvailability = FeatureAvailabilityManager.shared
private let scrollView = UIScrollView() private let scrollView = UIScrollView()
private let stackView = UIStackView() private let stackView = UIStackView()
private let periodSegmentedControl = ForecastTimePeriodControl(items: ["forecast.timePeriod.daily".localized(), private let periodSegmentedControl = ForecastTimePeriodControl(items: nil)
"forecast.timePeriod.minutely".localized()])
private let descriptionView = ForecastDescriptionView(lightStyleBackgroundColor: UIColor(hex: 0xd9ebfe), private let descriptionView = ForecastDescriptionView(lightStyleBackgroundColor: UIColor(hex: 0xd9ebfe),
gradientColors: [UIColor(hex: 0x44a4ff).withAlphaComponent(0.65).cgColor, gradientColors: [UIColor(hex: 0x44a4ff).withAlphaComponent(0.65).cgColor,
UIColor(hex: 0x73bbff).withAlphaComponent(0).cgColor]) UIColor(hex: 0x73bbff).withAlphaComponent(0).cgColor])
...@@ -43,6 +43,23 @@ class PrecipitationCell: UITableViewCell { ...@@ -43,6 +43,23 @@ class PrecipitationCell: UITableViewCell {
minutelyForecastView.configure(with: location, forecastType: .precipitation) minutelyForecastView.configure(with: location, forecastType: .precipitation)
//Update segment control
if featureAvailability?.isAvailable(feature: .minutelyForecast) == true {
periodSegmentedControl.set(items: ["forecast.timePeriod.daily".localized,
"forecast.timePeriod.minutely".localized()])
periodSegmentedControl.isHidden = false
periodSegmentedControl.snp.updateConstraints { update in
update.height.equalTo(40)
}
}
else {
periodSegmentedControl.set(items: ["forecast.timePeriod.daily".localized])
periodSegmentedControl.isHidden = true
periodSegmentedControl.snp.updateConstraints { update in
update.height.equalTo(0)
}
}
if stackView.arrangedSubviews.count != dayily.count { if stackView.arrangedSubviews.count != dayily.count {
let diff = stackView.arrangedSubviews.count - dayily.count let diff = stackView.arrangedSubviews.count - dayily.count
for _ in 0..<abs(diff) { for _ in 0..<abs(diff) {
...@@ -74,6 +91,8 @@ class PrecipitationCell: UITableViewCell { ...@@ -74,6 +91,8 @@ class PrecipitationCell: UITableViewCell {
precipButton.configure(with: dayily[index]) precipButton.configure(with: dayily[index])
} }
self.handleSegmentDidChange()
} }
public func configure(with hourly:[HourlyWeather], location: Location) { public func configure(with hourly:[HourlyWeather], location: Location) {
...@@ -84,6 +103,23 @@ class PrecipitationCell: UITableViewCell { ...@@ -84,6 +103,23 @@ class PrecipitationCell: UITableViewCell {
minutelyForecastView.configure(with: location, forecastType: .precipitation) minutelyForecastView.configure(with: location, forecastType: .precipitation)
//Update segment control
if featureAvailability?.isAvailable(feature: .minutelyForecast) == true {
periodSegmentedControl.set(items: ["forecast.timePeriod.hourly".localized,
"forecast.timePeriod.minutely".localized()])
periodSegmentedControl.isHidden = false
periodSegmentedControl.snp.updateConstraints { update in
update.height.equalTo(40)
}
}
else {
periodSegmentedControl.set(items: ["forecast.timePeriod.hourly".localized])
periodSegmentedControl.isHidden = true
periodSegmentedControl.snp.updateConstraints { update in
update.height.equalTo(0)
}
}
if stackView.arrangedSubviews.count != hourly.count { if stackView.arrangedSubviews.count != hourly.count {
let diff = stackView.arrangedSubviews.count - hourly.count let diff = stackView.arrangedSubviews.count - hourly.count
for _ in 0..<abs(diff) { for _ in 0..<abs(diff) {
...@@ -115,6 +151,8 @@ class PrecipitationCell: UITableViewCell { ...@@ -115,6 +151,8 @@ class PrecipitationCell: UITableViewCell {
precipButton.configure(with: hourly[index]) precipButton.configure(with: hourly[index])
} }
handleSegmentDidChange()
} }
//Private //Private
......
...@@ -30,6 +30,8 @@ protocol ForecastTimePeriodViewDelegate: AnyObject { ...@@ -30,6 +30,8 @@ protocol ForecastTimePeriodViewDelegate: AnyObject {
class ForecastTimePeriodView: UIView { class ForecastTimePeriodView: UIView {
//Private //Private
private let storeManager = StoreManager.shared
private let featureAvailability: FeatureAvailabilityManager = FeatureAvailabilityManager.shared
private let scrollView = UIScrollView() private let scrollView = UIScrollView()
private let stackView = UIStackView() private let stackView = UIStackView()
private let graphView = GraphView(graphInsets: .init(top: 0, left: 8, bottom: 0, right: 8)) private let graphView = GraphView(graphInsets: .init(top: 0, left: 8, bottom: 0, right: 8))
...@@ -63,11 +65,21 @@ class ForecastTimePeriodView: UIView { ...@@ -63,11 +65,21 @@ class ForecastTimePeriodView: UIView {
//Public //Public
public func set(daily:[DailyWeather]? = nil, hourly:[HourlyWeather]? = nil) { public func set(daily:[DailyWeather]? = nil, hourly:[HourlyWeather]? = nil) {
if let inputDaily = daily { if let inputDaily = daily {
self.daily = inputDaily if featureAvailability.isAvailable(feature: .extendedDailyForecast) {
self.daily = inputDaily
}
else {
self.daily = [DailyWeather](inputDaily.prefix(7))
}
} }
if let inputHourly = hourly { if let inputHourly = hourly {
self.hourly = inputHourly if featureAvailability.isAvailable(feature: .extendedHourlyForecast) {
self.hourly = inputHourly
}
else {
self.hourly = [HourlyWeather](inputHourly.prefix(24))
}
} }
} }
......
...@@ -32,8 +32,10 @@ private enum ForecastCellType { ...@@ -32,8 +32,10 @@ private enum ForecastCellType {
private struct HourlySection { private struct HourlySection {
let rows: [ForecastCellType] = { let rows: [ForecastCellType] = {
let showAds = !isAppPro() && AdConfigManager.shared.adConfig.adsEnabled // TODO: use dependency injection instead of a singleton
if showAds { let showAds = FeatureAvailabilityManager.shared.isAvailable(feature: .ads)
//TODO: dependency injection
if FeatureAvailabilityManager.shared.isAvailable(feature: .ads) {
return [.day, .forecastHourly, .adBanner, .precipitation, .wind, .adMREC] return [.day, .forecastHourly, .adBanner, .precipitation, .wind, .adMREC]
} }
else { else {
...@@ -44,8 +46,8 @@ private struct HourlySection { ...@@ -44,8 +46,8 @@ private struct HourlySection {
private struct DailySection { private struct DailySection {
let rows: [ForecastCellType] = { let rows: [ForecastCellType] = {
let showAds = !isAppPro() && AdConfigManager.shared.adConfig.adsEnabled //TODO: dependency injection
if showAds { if FeatureAvailabilityManager.shared.isAvailable(feature: .ads) {
return [.forecastDaily, .forecastDailyInfo, .adBanner, .sun, .moon, .adMREC] return [.forecastDaily, .forecastDailyInfo, .adBanner, .sun, .moon, .adMREC]
} }
else { else {
...@@ -60,7 +62,7 @@ class ForecastCellFactory: CellFactory { ...@@ -60,7 +62,7 @@ class ForecastCellFactory: CellFactory {
private let dailySection = DailySection() private let dailySection = DailySection()
private let hourlySection = HourlySection() private let hourlySection = HourlySection()
private var cellsToUpdate: Set<ForecastCellType> = [.forecastDaily, .forecastHourly, .forecastDailyInfo, .precipitation, .wind] private var cellsToUpdate: Set<ForecastCellType> = [.forecastDaily, .forecastHourly, .forecastDailyInfo, .precipitation, .wind]
private let forecastViewModel:ForecastViewModel private let forecastViewModel: ForecastViewModel
private var adViewCache = [TimePeriod: [IndexPath: AdView]]() private var adViewCache = [TimePeriod: [IndexPath: AdView]]()
//Public //Public
......
...@@ -196,7 +196,7 @@ public class LocationsViewModel { ...@@ -196,7 +196,7 @@ public class LocationsViewModel {
//MARK:- City CUD methods //MARK:- City CUD methods
func add(city: PartialLocation) { func add(city: PartialLocation) {
guard locationManager.locations.count < locationManager.maxLocationsCount else { guard locationManager.locations.count < locationManager.maxLocationsCount else {
self.delegate?.viewModelError(model: self, title: nil, message: "search.error.maxLocationWarning".localized()) self.delegate?.viewModelError(model: self, title: nil, message: String(format:"search.error.maxLocationWarning".localized(), "\(locationManager.maxLocationsCount)"))
return return
} }
locationManager.addIfNeeded(partialLocation: city, selectLocation: true) { success in locationManager.addIfNeeded(partialLocation: city, selectLocation: true) { success in
......
...@@ -9,6 +9,8 @@ import UIKit ...@@ -9,6 +9,8 @@ import UIKit
public enum MenuRow { public enum MenuRow {
case settings case settings
case subscriptionOverview
case restorePurchases
case about case about
case ad case ad
case rateUs case rateUs
...@@ -35,6 +37,10 @@ public enum MenuRow { ...@@ -35,6 +37,10 @@ public enum MenuRow {
return UIImage(named: "menu_privacyPolicy") return UIImage(named: "menu_privacyPolicy")
case .deviceId: case .deviceId:
return UIImage(named: "menu_device_id") return UIImage(named: "menu_device_id")
case .subscriptionOverview:
return UIImage(named: "menu_crown")
case .restorePurchases:
return UIImage(named: "menu_restore")
} }
} }
...@@ -56,13 +62,26 @@ public enum MenuRow { ...@@ -56,13 +62,26 @@ public enum MenuRow {
return "menu.privacy".localized() return "menu.privacy".localized()
case .deviceId: case .deviceId:
return "menu.deviceId".localized() return "menu.deviceId".localized()
case .subscriptionOverview:
return "menu.subscriptionOverview".localized()
case .restorePurchases:
return "menu.restorePurchases".localized()
} }
} }
var roundedCorners:CACornerMask { var roundedCorners:CACornerMask {
switch self { switch self {
case .settings: case .settings:
return [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] if !StoreManager.shared.hasSubscription && StoreManager.shared.removeAdsPurchased {
return [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
}
else {
return [.layerMinXMinYCorner, .layerMaxXMinYCorner]
}
case .restorePurchases:
return [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
case .subscriptionOverview:
return [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
case .about: case .about:
return [.layerMinXMinYCorner, .layerMaxXMinYCorner] return [.layerMinXMinYCorner, .layerMaxXMinYCorner]
case .ad: case .ad:
...@@ -94,8 +113,7 @@ private struct SectionItem { ...@@ -94,8 +113,7 @@ private struct SectionItem {
class MenuCellFactory<T>: CellFactory { class MenuCellFactory<T>: CellFactory {
//Private //Private
private let menuViewModel:MenuViewModel private let menuViewModel:MenuViewModel
private let sections:[SectionItem] = [SectionItem(type: .info, rows: [.settings]), private var sections = [SectionItem]()
SectionItem(type: .settings, rows: [.about, .ad, .help, .faq, .privacy, .deviceId])]
//Public //Public
public var kSectionHeight:CGFloat { public var kSectionHeight:CGFloat {
...@@ -110,6 +128,30 @@ class MenuCellFactory<T>: CellFactory { ...@@ -110,6 +128,30 @@ class MenuCellFactory<T>: CellFactory {
init(viewModel: MenuViewModel) { init(viewModel: MenuViewModel) {
self.menuViewModel = viewModel self.menuViewModel = viewModel
reload()
}
public func reload() {
let infoSectionItem: SectionItem
if StoreManager.shared.hasSubscription {
infoSectionItem = SectionItem(type: .info,
rows: [.settings, .subscriptionOverview])
}
else {
if StoreManager.shared.removeAdsPurchased {
infoSectionItem = SectionItem(type: .info,
rows: [.settings])
}
else {
infoSectionItem = SectionItem(type: .info,
rows: [.settings, .restorePurchases])
}
}
let settingsSectionItem = SectionItem(type: .settings,
rows: [.about, .ad, .help, .faq, .privacy, .deviceId])
self.sections = [infoSectionItem, settingsSectionItem]
} }
public func numberOfRows(inSection section:Int) -> Int { public func numberOfRows(inSection section:Int) -> Int {
......
...@@ -12,7 +12,7 @@ class MenuHeaderView: UIView { ...@@ -12,7 +12,7 @@ class MenuHeaderView: UIView {
private let premiumContainer = UIView() private let premiumContainer = UIView()
private let premiumHeadingLabel = UILabel() private let premiumHeadingLabel = UILabel()
private let premiumDescriptionLabel = UILabel() private let premiumDescriptionLabel = UILabel()
private let buyButton = MenuBuyButton() private let buyButton = SelfSizingButton()
private let premiumImageView = UIImageView() private let premiumImageView = UIImageView()
public var onTapBuy: (() -> ())? public var onTapBuy: (() -> ())?
...@@ -20,9 +20,11 @@ class MenuHeaderView: UIView { ...@@ -20,9 +20,11 @@ class MenuHeaderView: UIView {
init() { init() {
super.init(frame: .zero) super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
prepareView() prepareView()
preparePremium() preparePremium()
updateUI() updateUI()
reload()
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
...@@ -34,34 +36,45 @@ class MenuHeaderView: UIView { ...@@ -34,34 +36,45 @@ class MenuHeaderView: UIView {
updateUI() updateUI()
} }
public func reload() {
if StoreManager.shared.removeAdsPurchased {
premiumHeadingLabel.font = AppFont.SFPro.regular(size: 16)
premiumHeadingLabel.text = "menu.upgradeTo".localized()
let descParagpraphStyle = NSMutableParagraphStyle()
descParagpraphStyle.minimumLineHeight = 20
let descriptionAttrString = NSAttributedString(string: "menu.premiumMembership".localized(),
attributes: [.foregroundColor : UIColor.white,
.paragraphStyle : descParagpraphStyle,
.font : AppFont.SFPro.bold(size: 24)])
premiumDescriptionLabel.attributedText = descriptionAttrString
buyButton.setTitle("menu.upgradeNow".localized(), for: .normal)
}
else {
premiumHeadingLabel.font = AppFont.SFPro.bold(size: 23)
premiumHeadingLabel.text = "menu.goPremium".localized()
let descParagpraphStyle = NSMutableParagraphStyle()
descParagpraphStyle.minimumLineHeight = 20
let descriptionAttrString = NSAttributedString(string: "menu.premium.desc".localized(),
attributes: [.foregroundColor : UIColor.white,
.paragraphStyle : descParagpraphStyle,
.font : AppFont.SFPro.regular(size: 14)])
premiumDescriptionLabel.attributedText = descriptionAttrString
buyButton.setTitle("menu.buyNow".localized(), for: .normal)
}
}
//Prvaite //Prvaite
private func updateUI() { private func updateUI() {
backgroundColor = ThemeManager.currentTheme.baseBackgroundColor backgroundColor = ThemeManager.currentTheme.baseBackgroundColor
premiumContainer.backgroundColor = ThemeManager.currentTheme.containerBackgroundColor
premiumHeadingLabel.textColor = ThemeManager.currentTheme.primaryTextColor
premiumDescriptionLabel.textColor = ThemeManager.currentTheme.primaryTextColor
switch interfaceStyle {
case .light:
premiumContainer.layer.shadowColor = UIColor(hex: 0x020116).cgColor
premiumContainer.layer.shadowOffset = .init(width: 0, height: 10)
premiumContainer.layer.shadowRadius = 20
premiumContainer.layer.shadowOpacity = 0.12
case .dark:
premiumContainer.layer.shadowColor = UIColor.black.cgColor
premiumContainer.layer.shadowOffset = .init(width: 8, height: 8)
premiumContainer.layer.shadowRadius = 8
premiumContainer.layer.shadowOpacity = 1.0
}
} }
@objc private func handleBuyButton() { @objc private func handleBuyButton() {
onTapBuy?() onTapBuy?()
} }
@objc private func handleButton(button:MenuHeaderButton) {
}
} }
//MARK:- Prepare //MARK:- Prepare
...@@ -71,55 +84,60 @@ private extension MenuHeaderView { ...@@ -71,55 +84,60 @@ private extension MenuHeaderView {
} }
func preparePremium() { func preparePremium() {
premiumContainer.backgroundColor = ThemeManager.currentTheme.graphTintColor
premiumContainer.layer.cornerRadius = 12 premiumContainer.layer.cornerRadius = 12
addSubview(premiumContainer) addSubview(premiumContainer)
premiumHeadingLabel.font = AppFont.SFPro.bold(size: 34) premiumImageView.contentMode = .scaleAspectFit
premiumHeadingLabel.text = "menu.goPremium".localized() premiumImageView.image = UIImage(named: "subscription_logo")
premiumContainer.addSubview(premiumImageView)
premiumHeadingLabel.textColor = .white
premiumHeadingLabel.setContentHuggingPriority(.fittingSizeLevel, for: .vertical) premiumHeadingLabel.setContentHuggingPriority(.fittingSizeLevel, for: .vertical)
premiumContainer.addSubview(premiumHeadingLabel) premiumContainer.addSubview(premiumHeadingLabel)
premiumDescriptionLabel.lineBreakMode = .byWordWrapping premiumDescriptionLabel.lineBreakMode = .byWordWrapping
premiumDescriptionLabel.numberOfLines = 0 premiumDescriptionLabel.numberOfLines = 0
premiumDescriptionLabel.font = AppFont.SFPro.regular(size: 16)
premiumDescriptionLabel.text = "menu.premium.desc".localized()
premiumDescriptionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) premiumDescriptionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
premiumContainer.addSubview(premiumDescriptionLabel) premiumContainer.addSubview(premiumDescriptionLabel)
buyButton.backgroundColor = .white
buyButton.titleEdgeInsets = .init(top: 0, left: 10, bottom: 0, right: 10)
buyButton.setTitleColor(ThemeManager.currentTheme.graphTintColor, for: .normal)
buyButton.titleLabel?.font = AppFont.SFPro.semibold(size: 15)
buyButton.layer.cornerRadius = 4
buyButton.addTarget(self, action: #selector(handleBuyButton), for: .touchUpInside) buyButton.addTarget(self, action: #selector(handleBuyButton), for: .touchUpInside)
premiumContainer.addSubview(buyButton) premiumContainer.addSubview(buyButton)
premiumImageView.contentMode = .scaleAspectFit
premiumImageView.image = UIImage(named: "premium")
premiumContainer.addSubview(premiumImageView)
//Constraints //Constraints
premiumContainer.snp.makeConstraints { (make) in premiumContainer.snp.makeConstraints { (make) in
make.top.equalTo(30) make.top.equalTo(30)
make.left.right.equalToSuperview().inset(18).priority(999) make.left.right.equalToSuperview().inset(18).priority(999)
make.height.equalTo(180)
make.bottom.equalToSuperview().inset(30) make.bottom.equalToSuperview().inset(30)
} }
premiumImageView.snp.makeConstraints { (make) in premiumImageView.snp.makeConstraints { (make) in
make.width.height.equalTo(140) make.top.equalToSuperview().inset(20)
make.right.bottom.equalToSuperview() make.bottom.equalToSuperview().inset(12)
make.right.equalToSuperview().inset(15)
} }
premiumHeadingLabel.snp.makeConstraints { (make) in premiumHeadingLabel.snp.makeConstraints { (make) in
make.left.top.equalToSuperview().inset(18) make.left.equalToSuperview().inset(18)
make.top.equalToSuperview().inset(26)
} }
premiumDescriptionLabel.snp.makeConstraints { (make) in premiumDescriptionLabel.snp.makeConstraints { (make) in
make.left.equalToSuperview().inset(18) make.left.equalToSuperview().inset(18)
make.top.equalTo(premiumHeadingLabel.snp.bottom) make.top.equalTo(premiumHeadingLabel.snp.bottom).offset(10)
make.right.equalTo(premiumImageView.snp.left) make.width.equalToSuperview().multipliedBy(0.53)
} }
buyButton.snp.makeConstraints { (make) in buyButton.snp.makeConstraints { (make) in
make.height.equalTo(35)
make.top.equalTo(premiumDescriptionLabel.snp.bottom).offset(18).priority(999) make.top.equalTo(premiumDescriptionLabel.snp.bottom).offset(18).priority(999)
make.left.equalToSuperview().inset(18) make.left.equalToSuperview().inset(18)
make.bottom.equalToSuperview().inset(24) make.bottom.equalToSuperview().inset(21)
} }
} }
} }
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
import UIKit import UIKit
import SafariServices import SafariServices
import OneWeatherCore import OneWeatherCore
import OneWeatherAnalytics
class MenuViewController: UIViewController { class MenuViewController: UIViewController {
//Private //Private
...@@ -52,12 +53,21 @@ class MenuViewController: UIViewController { ...@@ -52,12 +53,21 @@ class MenuViewController: UIViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
StoreManager.shared.add(observer: self)
prepareController() prepareController()
prepareTableViewHeader() prepareTableViewHeader()
prepareTableView() prepareTableView()
updateUI() updateUI()
} }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
menuCellFactory.reload()
menuHeaderView.reload()
tableView.reloadData()
}
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
tableView.layoutTableHeaderView() tableView.layoutTableHeaderView()
...@@ -73,10 +83,6 @@ class MenuViewController: UIViewController { ...@@ -73,10 +83,6 @@ class MenuViewController: UIViewController {
view.backgroundColor = ThemeManager.currentTheme.baseBackgroundColor view.backgroundColor = ThemeManager.currentTheme.baseBackgroundColor
tableView.backgroundColor = ThemeManager.currentTheme.baseBackgroundColor tableView.backgroundColor = ThemeManager.currentTheme.baseBackgroundColor
} }
private func upgradeToPro() {
viewModel.updateToPro()
}
} }
//MARK:- Prepare //MARK:- Prepare
...@@ -87,7 +93,14 @@ private extension MenuViewController { ...@@ -87,7 +93,14 @@ private extension MenuViewController {
func prepareTableViewHeader() { func prepareTableViewHeader() {
menuHeaderView.onTapBuy = { [weak self] in menuHeaderView.onTapBuy = { [weak self] in
self?.viewModel.updateToPro() if StoreManager.shared.removeAdsPurchased {
AppAnalytics.shared.log(event: .ANALYTICS_SUBSCRIPTION_UPGRADE_CARD_CLICK)
}
else {
AppAnalytics.shared.log(event: .ANALYTICS_SUBSCRIPTION_PREMIUM_CARD_CLICK)
}
self?.coordinator.openSubscriptions()
} }
} }
...@@ -109,6 +122,9 @@ private extension MenuViewController { ...@@ -109,6 +122,9 @@ private extension MenuViewController {
tableView.snp.makeConstraints { (make) in tableView.snp.makeConstraints { (make) in
make.edges.equalToSuperview() make.edges.equalToSuperview()
} }
if self.viewModel.storeManager.hasSubscription || !self.viewModel.featureAvailabilityManager.isAvailable(feature: .subscription) {
self.tableView.tableHeaderView = nil
}
} }
} }
...@@ -148,16 +164,33 @@ extension MenuViewController: UITableViewDelegate { ...@@ -148,16 +164,33 @@ extension MenuViewController: UITableViewDelegate {
viewModel.viewPrivacyPolicy() viewModel.viewPrivacyPolicy()
case .deviceId: case .deviceId:
viewModel.showDeviceId() viewModel.showDeviceId()
case .subscriptionOverview:
AppAnalytics.shared.log(event: .ANALYTICS_SUBSCRIPTION_PREMIUM_HAMBURGER_CLICK)
coordinator.openSubscriptionOverview()
case .restorePurchases:
viewModel.restorePurchases()
default: default:
break break
} }
} }
} }
//MARK:- StoreManager Observer
extension MenuViewController: StoreManagerObserver {
func storeManagerUpdatedStatus(_ storeManager: StoreManager) {
onMain {
self.menuCellFactory.reload()
self.menuHeaderView.reload()
self.tableView.reloadData()
}
}
}
//MARK:- MenuViewModel Delegate
extension MenuViewController: MenuViewModelDelegate { extension MenuViewController: MenuViewModelDelegate {
func viewModelDidChange<P: ViewModelProtocol>(model:P) { func viewModelDidChange<P: ViewModelProtocol>(model:P) {
onMain { onMain {
if isAppPro() { if self.viewModel.storeManager.hasSubscription || !self.viewModel.featureAvailabilityManager.isAvailable(feature: .subscription) {
self.tableView.tableHeaderView = nil self.tableView.tableHeaderView = nil
} }
self.tableView.reloadData() self.tableView.reloadData()
......
...@@ -33,7 +33,8 @@ fileprivate struct HeaderSection: NWSAlertTableViewSection { ...@@ -33,7 +33,8 @@ fileprivate struct HeaderSection: NWSAlertTableViewSection {
} }
private let rows: [NWSAlertCellType] = { private let rows: [NWSAlertCellType] = {
if !isAppPro() && AdConfigManager.shared.adConfig.adsEnabled { //TODO: dependency injection
if FeatureAvailabilityManager.shared.isAvailable(feature: .ads) {
return [.header, .forecastOffice, .adBanner] return [.header, .forecastOffice, .adBanner]
} }
else { else {
......
...@@ -11,7 +11,7 @@ import OneWeatherAnalytics ...@@ -11,7 +11,7 @@ import OneWeatherAnalytics
class NWSAlertViewController: UIViewController { class NWSAlertViewController: UIViewController {
private let coordinator: NWSAlertCoordinator private let coordinator: NWSAlertCoordinator
private let viewModel: NWSAlertViewModel private let viewModel: NWSAlertViewModel
private var localizationObserver: Any? private var localizationObserver: LocalizationChangeObserver!
private let tableView = UITableView() private let tableView = UITableView()
...@@ -35,13 +35,7 @@ class NWSAlertViewController: UIViewController { ...@@ -35,13 +35,7 @@ class NWSAlertViewController: UIViewController {
super.viewDidAppear(animated) super.viewDidAppear(animated)
analytics(log: .ANALYTICS_VIEW_ALERT_DETAILS) analytics(log: .ANALYTICS_VIEW_ALERT_DETAILS)
} }
deinit {
if let observer = localizationObserver {
NotificationCenter.default.removeObserver(observer)
}
}
private func close() { private func close() {
self.navigationController?.dismiss(animated: true, completion: { [weak self] in self.navigationController?.dismiss(animated: true, completion: { [weak self] in
guard let self = self else { return } guard let self = self else { return }
...@@ -64,9 +58,9 @@ extension NWSAlertViewController { ...@@ -64,9 +58,9 @@ extension NWSAlertViewController {
action: #selector(handleCloseButton)) action: #selector(handleCloseButton))
navigationItem.leftBarButtonItem = closeButton navigationItem.leftBarButtonItem = closeButton
localizationObserver = NotificationCenter.default.addObserver(forName: .localizationChange, object: nil, queue: .main, using: { [weak self] _ in localizationObserver = LocalizationChangeObserver { [weak self] in
self?.prepareNavigationBar() self?.prepareNavigationBar()
}) }
} }
func prepareNavigationBar() { func prepareNavigationBar() {
......
...@@ -12,7 +12,7 @@ import OneWeatherAnalytics ...@@ -12,7 +12,7 @@ import OneWeatherAnalytics
class NotificationsViewController: UIViewController { class NotificationsViewController: UIViewController {
private let coordinator: NotificationsCoordinator private let coordinator: NotificationsCoordinator
private let viewModel = NotificationsViewModel() private let viewModel = NotificationsViewModel()
private var localizationObserver: Any? private var localizationObserver: LocalizationChangeObserver!
private let log = Logger(componentName: "NotificationsViewController") private let log = Logger(componentName: "NotificationsViewController")
private let tableView = UITableView() private let tableView = UITableView()
...@@ -40,13 +40,6 @@ class NotificationsViewController: UIViewController { ...@@ -40,13 +40,6 @@ class NotificationsViewController: UIViewController {
analytics(log: .ANALYTICS_VIEW_ALERTS) analytics(log: .ANALYTICS_VIEW_ALERTS)
} }
deinit {
if let observer = localizationObserver {
NotificationCenter.default.removeObserver(observer)
}
}
private func close() { private func close() {
self.navigationController?.dismiss(animated: true, completion: { [weak self] in self.navigationController?.dismiss(animated: true, completion: { [weak self] in
guard let self = self else { return } guard let self = self else { return }
...@@ -69,10 +62,10 @@ private extension NotificationsViewController { ...@@ -69,10 +62,10 @@ private extension NotificationsViewController {
action: #selector(handleCloseButton)) action: #selector(handleCloseButton))
navigationItem.leftBarButtonItem = closeButton navigationItem.leftBarButtonItem = closeButton
localizationObserver = NotificationCenter.default.addObserver(forName: .localizationChange, object: nil, queue: .main, using: { [weak self] _ in localizationObserver = LocalizationChangeObserver { [weak self] in
self?.tableView.reloadData() self?.tableView.reloadData()
self?.prepareNavigationBar() self?.prepareNavigationBar()
}) }
} }
func prepareNavigationBar() { func prepareNavigationBar() {
......
...@@ -61,6 +61,8 @@ private extension OnboardingContentController { ...@@ -61,6 +61,8 @@ private extension OnboardingContentController {
view.addSubview(titleLabel) view.addSubview(titleLabel)
subtitleLabel.font = AppFont.SFPro.regular(size: 14) subtitleLabel.font = AppFont.SFPro.regular(size: 14)
subtitleLabel.lineBreakMode = .byWordWrapping
subtitleLabel.numberOfLines = 0
view.addSubview(subtitleLabel) view.addSubview(subtitleLabel)
//Constraints //Constraints
...@@ -70,7 +72,7 @@ private extension OnboardingContentController { ...@@ -70,7 +72,7 @@ private extension OnboardingContentController {
} }
subtitleLabel.snp.makeConstraints { make in subtitleLabel.snp.makeConstraints { make in
make.left.equalToSuperview().inset(30) make.left.right.equalToSuperview().inset(30)
make.top.equalTo(titleLabel.snp.bottom).offset(10) make.top.equalTo(titleLabel.snp.bottom).offset(10)
} }
} }
......
...@@ -6,13 +6,14 @@ ...@@ -6,13 +6,14 @@
// //
import UIKit import UIKit
import OneWeatherCore
import OneWeatherAnalytics import OneWeatherAnalytics
class SettingsViewController: UIViewController { class SettingsViewController: UIViewController {
//Private //Private
private let coordinator:SettingsCoordinator private let coordinator: SettingsCoordinator
private let settingsCellFactory:SettingsCellFactory private let settingsCellFactory: SettingsCellFactory
private let viewModel:SettingsViewModel private let viewModel: SettingsViewModel
private let tableView = UITableView(frame: .zero, style: .grouped) private let tableView = UITableView(frame: .zero, style: .grouped)
...@@ -173,7 +174,7 @@ extension SettingsViewController: UITableViewDelegate { ...@@ -173,7 +174,7 @@ extension SettingsViewController: UITableViewDelegate {
case .other: case .other:
switch settingsCellFactory.rowTypeAt(indexPath: indexPath) { switch settingsCellFactory.rowTypeAt(indexPath: indexPath) {
case .locationAccess: case .locationAccess:
UIApplication.openSettings() Settings.shared.openDeviceSettings()
default: default:
break break
} }
......
...@@ -50,6 +50,10 @@ class ShortsViewController: UIViewController { ...@@ -50,6 +50,10 @@ class ShortsViewController: UIViewController {
return scheduler return scheduler
}() }()
private var featureAvailabilityManager: FeatureAvailabilityManager {
FeatureAvailabilityManager.shared
}
deinit { deinit {
coordinator.viewControllerDidEnd(controller: self) coordinator.viewControllerDidEnd(controller: self)
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
...@@ -150,11 +154,11 @@ class ShortsViewController: UIViewController { ...@@ -150,11 +154,11 @@ class ShortsViewController: UIViewController {
} }
//Show the swipe helper view if needed //Show the swipe helper view if needed
if ConfigManager.shared.config.shortsLastNudgeEnabled { if featureAvailabilityManager.isAvailable(feature: .shortsLastNudge) {
//Check for the last row //Check for the last row
if rowIndex == viewModel.shorts.count - 1 { if rowIndex == viewModel.shorts.count - 1 {
swipeHelperView.configure(forState: .downViewedAll) swipeHelperView.configure(forState: .downViewedAll)
analytics(log:.ANALYTICS_SHORTS_NUDGE_VIEW, params: [.ANALYTICS_KEY_SHORTS_NUDGE_VIEW_TYPE: "last_card"]) analytics(log:.ANALYTICS_SHORTS_NUDGE_VIEW, params: [.ANALYTICS_KEY_GENERAL_TYPE: "last_card"])
UIView.animate(withDuration: 0.3) { UIView.animate(withDuration: 0.3) {
self.swipeHelperView.alpha = 1 self.swipeHelperView.alpha = 1
} }
...@@ -164,7 +168,7 @@ class ShortsViewController: UIViewController { ...@@ -164,7 +168,7 @@ class ShortsViewController: UIViewController {
if Settings.shared.shortsSwipeUpNudgeShowedCount <= ConfigManager.shared.config.shortsSwipeUpNudgeCount { if Settings.shared.shortsSwipeUpNudgeShowedCount <= ConfigManager.shared.config.shortsSwipeUpNudgeCount {
swipeHelperView.configure(forState: .upNext) swipeHelperView.configure(forState: .upNext)
analytics(log:.ANALYTICS_SHORTS_NUDGE_VIEW, params: [.ANALYTICS_KEY_SHORTS_NUDGE_VIEW_TYPE: "swipe_up"]) analytics(log:.ANALYTICS_SHORTS_NUDGE_VIEW, params: [.ANALYTICS_KEY_GENERAL_TYPE: "swipe_up"])
UIView.animate(withDuration: 0.3) { UIView.animate(withDuration: 0.3) {
self.swipeHelperView.alpha = 1 self.swipeHelperView.alpha = 1
} }
...@@ -186,7 +190,7 @@ class ShortsViewController: UIViewController { ...@@ -186,7 +190,7 @@ class ShortsViewController: UIViewController {
} }
private func showUnreadShortsView(willShowRowIndex index:Int) { private func showUnreadShortsView(willShowRowIndex index:Int) {
analytics(log:.ANALYTICS_SHORTS_NUDGE_VIEW, params: [.ANALYTICS_KEY_SHORTS_NUDGE_VIEW_TYPE: "left_below"]) analytics(log:.ANALYTICS_SHORTS_NUDGE_VIEW, params: [.ANALYTICS_KEY_GENERAL_TYPE: "left_below"])
swipeUpCounter = 0 swipeUpCounter = 0
let unreadCount = max(0, viewModel.shorts.count - index) let unreadCount = max(0, viewModel.shorts.count - index)
......
//
// SubscriptionOverviewViewController.swift
// 1Weather
//
// Created by Demid Merzlyakov on 10.09.2021.
//
import UIKit
import OneWeatherAnalytics
/// A screen where the user is thanked for purchasing a subscription. It is shown after a successful subscription purchase or if the user goes into the Menu and chooses "Premium" item there.
/// https://zpl.io/aBPo75K
class SubscriptionOverviewViewController: UIViewController {
private let coordinator: SubscriptionOverviewCoordinator
private let scrollView = UIScrollView()
private let scrollViewContent = UIView()
private let topHeaderView = SubscriptionTopView()
private let descriptionView = SubscriptionDescriptionView(alreadyPurchased: true)
let footerView = UILabel()
init(coordinator: SubscriptionOverviewCoordinator) {
self.coordinator = coordinator
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { return nil }
public override func viewDidLoad() {
super.viewDidLoad()
prepareViewController()
prepareFooter()
prepareScrollView()
//Verify new subscription
StoreManager.shared.verifyPurchases()
if StoreManager.shared.removeAdsPurchased {
//Pro
AppAnalytics.shared.log(event: .ANALYTICS_SUBSCRIPTION_VIEW_PREMIUM_SUCCESS,
params: [.ANALYTICS_KEY_GENERAL_TYPE : "pro"])
}
else {
AppAnalytics.shared.log(event: .ANALYTICS_SUBSCRIPTION_VIEW_PREMIUM_SUCCESS,
params: [.ANALYTICS_KEY_GENERAL_TYPE : "regular"])
}
}
}
//MARK: - UI Setup
extension SubscriptionOverviewViewController {
private func prepareViewController() {
topHeaderView.delegate = self
view.backgroundColor = ThemeManager.currentTheme.baseBackgroundColor
}
private func prepareScrollView() {
view.addSubview(scrollView)
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
scrollView.addSubview(scrollViewContent)
scrollViewContent.snp.makeConstraints { make in
make.top.bottom.left.right.equalToSuperview()
make.width.equalToSuperview()
}
scrollViewContent.addSubview(topHeaderView)
topHeaderView.snp.makeConstraints { make in
make.top.equalToSuperview()
make.left.right.equalToSuperview()
}
scrollViewContent.addSubview(descriptionView)
descriptionView.snp.makeConstraints { make in
make.top.equalTo(topHeaderView.snp.bottom).offset(24)
make.left.right.equalToSuperview().inset(24)
}
scrollViewContent.addSubview(footerView)
footerView.snp.makeConstraints { make in
make.top.equalTo(descriptionView.snp.bottom).offset(40)
make.left.right.equalToSuperview().inset(24)
make.bottom.equalToSuperview().inset(100)
}
}
private func prepareFooter() {
footerView.font = AppFont.SFPro.regular(size: 14)
footerView.textColor = ThemeManager.currentTheme.secondaryTextColor
footerView.numberOfLines = 0
footerView.text = "subscription.description.cancellation".localized()
}
}
extension SubscriptionOverviewViewController: SubscriptionTopViewDelegate {
func handleCloseButton() {
self.dismiss(animated: true)
}
}
//
// SubscriptionStoreViewController.swift
// 1Weather
//
// Created by Demid Merzlyakov on 10.09.2021.
//
import UIKit
import SnapKit
import StoreKit
import OneWeatherAnalytics
/// A screen containing buttons to purchase a subscription.
/// Relevant design URLs:
/// For users who previously purchased an in-app to disable ads: https://zpl.io/anzPMKr
/// For usual users: https://zpl.io/bLJ8rOd
public class SubscriptionStoreViewController: UIViewController {
private let coordinator: SubscriptionCoordinator
private let viewModel: SubscriptionViewModel
private var localizationObserver: LocalizationChangeObserver!
private let scrollView = UIScrollView()
private let scrollViewContent = UIView()
private let topHeaderView = SubscriptionTopView()
private let dynamicContentView = UIView()
init(coordinator: SubscriptionCoordinator, viewModel: SubscriptionViewModel) {
self.coordinator = coordinator
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
self.viewModel.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) { return nil }
public override func viewDidLoad() {
super.viewDidLoad()
prepareViewController()
prepareScrollView()
rebuildUI()
}
}
//MARK: - UI Setup
extension SubscriptionStoreViewController {
private func prepareViewController() {
view.backgroundColor = ThemeManager.currentTheme.baseBackgroundColor
topHeaderView.delegate = self
localizationObserver = LocalizationChangeObserver { [weak self] in
self?.rebuildUI()
}
}
private func prepareScrollView() {
view.addSubview(scrollView)
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
scrollView.addSubview(scrollViewContent)
scrollViewContent.snp.makeConstraints { make in
make.top.bottom.left.right.equalToSuperview()
make.width.equalToSuperview()
}
scrollViewContent.addSubview(topHeaderView)
topHeaderView.snp.makeConstraints { make in
make.top.left.right.equalToSuperview()
}
scrollViewContent.addSubview(dynamicContentView)
dynamicContentView.translatesAutoresizingMaskIntoConstraints = false
dynamicContentView.snp.makeConstraints { make in
make.top.equalTo(topHeaderView.snp.bottom)
make.left.right.equalToSuperview().inset(24)
make.bottom.equalToSuperview()
}
}
private func rebuildUI() {
for subview in dynamicContentView.subviews {
subview.removeFromSuperview()
}
let descriptionView: UIView
if viewModel.isProUser {
AppAnalytics.shared.log(event: .ANALYTICS_SUBSCRIPTION_VIEW_PREMIUM_PRO_SCREEN)
descriptionView = SubscriptionProDescriptionView()
}
else {
AppAnalytics.shared.log(event: .ANALYTICS_SUBSCRIPTION_VIEW_PREMIUM_SCREEN)
descriptionView = SubscriptionDescriptionView(alreadyPurchased: false)
}
dynamicContentView.addSubview(descriptionView)
descriptionView.snp.makeConstraints { make in
make.top.left.right.equalToSuperview()
}
var lastView: UIView = descriptionView
if let yearly = viewModel.storeManager.yearly {
let yearlyButton = SubscriptionPurchaseButton(type: .yearly, subscription: yearly, discountSubscription: viewModel.isProUser ? viewModel.storeManager.yearlyDiscounted : nil)
dynamicContentView.addSubview(yearlyButton)
yearlyButton.snp.makeConstraints { make in
make.top.equalTo(lastView.snp.bottom).offset(20)
make.left.right.equalToSuperview()
}
lastView = yearlyButton
yearlyButton.delegate = self
}
if let monthly = viewModel.storeManager.monthly {
let monthlyButton = SubscriptionPurchaseButton(type: .monthly, subscription: monthly, discountSubscription: viewModel.isProUser ? viewModel.storeManager.monthlyDiscounted : nil)
dynamicContentView.addSubview(monthlyButton)
monthlyButton.snp.makeConstraints { make in
make.top.equalTo(lastView.snp.bottom).offset(20)
make.left.right.equalToSuperview()
}
lastView = monthlyButton
monthlyButton.delegate = self
}
lastView.snp.makeConstraints { make in
make.bottom.equalToSuperview().inset(32)
}
}
}
extension SubscriptionStoreViewController: SubscriptionTopViewDelegate {
func handleCloseButton() {
self.dismiss(animated: true)
}
}
extension SubscriptionStoreViewController: SubscriptionPurchaseButtonDelegate {
func button(_ button: SubscriptionPurchaseButton, triggeredPurchaseOf product: SKProduct) {
if viewModel.isProUser {
if button.subscriptionType == .yearly {
AppAnalytics.shared.log(event: .ANALYTICS_SUBSCRIPTION_UPGRADE_YEARLY_CLICK)
}
else {
AppAnalytics.shared.log(event: .ANALYTICS_SUBSCRIPTION_UPGRADE_MONTHLY_CLICK)
}
}
else {
if button.subscriptionType == .yearly {
AppAnalytics.shared.log(event: .ANALYTICS_SUBSCRIPTION_SUBSCRIBE_YEARLY_CLICK)
}
else {
AppAnalytics.shared.log(event: .ANALYTICS_SUBSCRIPTION_SUBSCRIBE_MONTHLY_CLICK)
}
}
viewModel.purchase(subscription: product)
}
}
extension SubscriptionStoreViewController: SubscriptionViewModelDelegate {
func viewModel(_ vm: SubscriptionViewModel, finishedSubscriptionPurchaseWithResult result: Bool) {
if result {
self.dismiss(animated: true) {
self.coordinator.openOverview()
}
}
}
}
//
// SubscriptionDescriptionView.swift
// 1Weather
//
// Created by Demid Merzlyakov on 10.09.2021.
//
import UIKit
import SnapKit
/// A view containing decription of features of premium subscription for users who HAVE NOT previously purchased an in-app to remove ads.
class SubscriptionDescriptionView: UIView {
private let header = UILabel()
private let stackView = UIStackView()
private let alreadyPurchased: Bool
public init(alreadyPurchased: Bool) {
self.alreadyPurchased = alreadyPurchased
super.init(frame: .zero)
self.translatesAutoresizingMaskIntoConstraints = false
prepareHeader()
prepareStackView()
prepareItems()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func prepareHeader() {
header.font = AppFont.SFPro.semibold(size: 24)
header.textColor = ThemeManager.currentTheme.primaryTextColor
header.numberOfLines = 0
header.textAlignment = .center
if alreadyPurchased {
header.text = "subscription.description.header.purchased".localized()
}
else {
header.text = "subscription.description.header".localized()
}
addSubview(header)
header.snp.makeConstraints { make in
make.top.equalToSuperview().inset(32)
make.centerX.equalToSuperview()
make.left.greaterThanOrEqualToSuperview()
make.right.lessThanOrEqualToSuperview()
}
}
private func prepareStackView() {
addSubview(stackView)
stackView.axis = .vertical
stackView.spacing = 24
stackView.alignment = .leading
stackView.snp.makeConstraints { make in
make.top.equalTo(header.snp.bottom).offset(28)
make.left.right.bottom.equalToSuperview()
}
}
private func prepareItems() {
let checkmarkImage = UIImage(named: "subscription_checkmark") ?? UIImage()
let starImage = UIImage(named: "subscription_star") ?? UIImage()
if alreadyPurchased {
let youGetLabel = UILabel()
youGetLabel.font = AppFont.SFPro.bold(size: 16)
youGetLabel.textColor = ThemeManager.currentTheme.primaryTextColor
youGetLabel.text = "subscription.description.items.youGet".localized()
stackView.addArrangedSubview(youGetLabel)
}
let adFreeItem = DescriptionItemView(image: checkmarkImage, textShortLocalizedKey: "items.adFree")
stackView.addArrangedSubview(adFreeItem)
let forever = prepareForeverMarker()
addSubview(forever)
forever.snp.makeConstraints { make in
make.leading.equalTo(adFreeItem.snp.trailing).offset(8)
make.centerY.equalTo(adFreeItem.snp.centerY)
}
let hourlyItem = DescriptionItemView(image: checkmarkImage, textShortLocalizedKey: "items.hourly", crossedOutTextShortLocalizedKey: "items.hourly.crossed")
stackView.addArrangedSubview(hourlyItem)
let dailyItem = DescriptionItemView(image: checkmarkImage, textShortLocalizedKey: "items.daily", crossedOutTextShortLocalizedKey: "items.daily.crossed")
stackView.addArrangedSubview(dailyItem)
let aqiItem = DescriptionItemView(image: checkmarkImage, textShortLocalizedKey: "items.aqi")
stackView.addArrangedSubview(aqiItem)
let comingSoonSpacerView = UIView()
comingSoonSpacerView.backgroundColor = .clear
comingSoonSpacerView.snp.makeConstraints { make in
make.height.equalTo(8)
}
stackView.addArrangedSubview(comingSoonSpacerView)
let comingSoonView = prepareComingSoon()
addSubview(comingSoonView)
comingSoonView.snp.makeConstraints { make in
make.leading.equalTo(self.snp.leading).inset(-24) // a hack)
make.centerY.equalTo(comingSoonSpacerView.snp.centerY)
}
let minutelyItem = DescriptionItemView(image: starImage, textShortLocalizedKey: "items.minutely")
stackView.addArrangedSubview(minutelyItem)
}
private func prepareForeverMarker() -> UIView {
let nonClippingView = UIView()
nonClippingView.clipsToBounds = false
let view = UIView()
view.backgroundColor = UIColor(named: "subscription_no_ads_forever_background")
view.layer.cornerRadius = 4
view.clipsToBounds = true
let label = UILabel()
view.addSubview(label)
label.textColor = .black
label.font = AppFont.SFPro.semibold(size: 10)
label.text = "subscription.description.items.adFree.forever".localized()
label.snp.makeConstraints { make in
make.top.equalToSuperview().inset(2)
make.bottom.equalToSuperview().inset(3)
make.left.right.equalToSuperview().inset(6)
}
nonClippingView.addSubview(view)
view.snp.makeConstraints { make in
make.top.left.right.bottom.equalToSuperview()
}
let raysIcon = UIImageView()
raysIcon.image = UIImage(named: "subscription_no_ads_forever_rays")
nonClippingView.addSubview(raysIcon)
raysIcon.snp.makeConstraints { make in
make.width.height.equalTo(16)
make.bottom.equalTo(view.snp.top).offset(4)
make.leading.equalTo(view.snp.trailing).offset(-4)
}
return nonClippingView
}
private func prepareComingSoon() -> UIView {
let view = UIView()
view.backgroundColor = UIColor(named: "subscription_coming_soon_background")
view.layer.cornerRadius = 12
view.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
view.clipsToBounds = true
let label = UILabel()
view.addSubview(label)
label.textColor = .black
label.font = AppFont.SFPro.semibold(size: 10)
label.text = "subscription.description.items.comingSoon".localized()
label.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(6)
make.left.right.equalToSuperview().inset(12)
}
return view
}
}
extension SubscriptionDescriptionView {
private class DescriptionItemView: UIView {
private let iconView = UIImageView()
private let textView = UILabel()
public init(image: UIImage, textShortLocalizedKey: String, crossedOutTextShortLocalizedKey: String? = nil) {
textView.textColor = ThemeManager.currentTheme.primaryTextColor
let text = ("subscription.description." + textShortLocalizedKey).localized()
super.init(frame: .zero)
self.translatesAutoresizingMaskIntoConstraints = false
let defaultAttributes: [NSAttributedString.Key: Any] = [
NSAttributedString.Key.font: AppFont.SFPro.regular(size: 16)
]
let attributedText = NSMutableAttributedString(string: text, attributes: defaultAttributes)
if let crossedOutTextShortLocalizedKey = crossedOutTextShortLocalizedKey {
let crossedOutText = ("subscription.description." + crossedOutTextShortLocalizedKey).localized()
let crossedOutAttributes: [NSAttributedString.Key: Any] = [
NSAttributedString.Key.font: AppFont.SFPro.light(size: 16),
NSAttributedString.Key.strikethroughStyle: NSUnderlineStyle.single.rawValue
]
attributedText.insert(NSAttributedString(string: crossedOutText, attributes: crossedOutAttributes), at: 0)
}
textView.attributedText = attributedText
textView.adjustsFontSizeToFitWidth = true
textView.minimumScaleFactor = 0.2
iconView.image = image
addSubview(iconView)
addSubview(textView)
iconView.snp.makeConstraints { make in
make.height.width.equalTo(20)
make.leading.top.bottom.equalToSuperview()
}
textView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview()
make.leading.equalTo(iconView.snp.trailing).offset(10)
make.trailing.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
}
//
// SubscriptionPurchaseButton.swift
// 1Weather
//
// Created by Demid Merzlyakov on 10.09.2021.
//
import UIKit
import StoreKit
protocol SubscriptionPurchaseButtonDelegate: AnyObject {
func button(_ button: SubscriptionPurchaseButton, triggeredPurchaseOf product: SKProduct)
}
class SubscriptionPurchaseButton: UIButton {
public enum SubscriptionType: String {
case monthly
case yearly
}
private let discountSubscription: SKProduct?
private let subscription: SKProduct
public let subscriptionType: SubscriptionPurchaseButton.SubscriptionType
public weak var delegate: SubscriptionPurchaseButtonDelegate?
public init(type: SubscriptionPurchaseButton.SubscriptionType, subscription: SKProduct, discountSubscription: SKProduct? = nil) {
self.discountSubscription = discountSubscription
self.subscription = subscription
self.subscriptionType = type
super.init(frame: .zero)
self.titleLabel?.adjustsFontSizeToFitWidth = true
self.titleLabel?.minimumScaleFactor = 0.2
self.translatesAutoresizingMaskIntoConstraints = false
self.titleEdgeInsets = .init(top: 0, left: 16, bottom: 0, right: 16)
self.setAttributedTitle(buildTitle(forType: type), for: .normal)
if type == .yearly {
self.setTitleColor(ThemeManager.currentTheme.subscriptionPurchaseColor, for: .normal)
self.backgroundColor = ThemeManager.currentTheme.subscriptionPurchaseBackgroundColor
}
else {
self.setTitleColor(ThemeManager.currentTheme.subscriptionPurchaseBackgroundColor, for: .normal)
self.backgroundColor = ThemeManager.currentTheme.subscriptionPurchaseColor
}
self.layer.borderWidth = 1
self.layer.borderColor = ThemeManager.currentTheme.subscriptionPurchaseBackgroundColor.cgColor
self.clipsToBounds = true
self.layer.cornerRadius = 6
self.snp.makeConstraints { make in
make.height.equalTo(52)
}
addTarget(self, action: #selector(handleTap), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func buildTitle(forType type: SubscriptionPurchaseButton.SubscriptionType) -> NSAttributedString {
guard let price = subscription.localizedPrice else {
return NSAttributedString()
}
let template = "subscription.button.\(showUpgradeText ? "upgrade" : "buy").\(subscriptionType.rawValue)".localized()
let priceAttributes: [NSAttributedString.Key: Any]
if showUpgradeText {
priceAttributes = [
NSAttributedString.Key.font: AppFont.SFPro.light(size: 18),
NSAttributedString.Key.strikethroughStyle: NSUnderlineStyle.single.rawValue
]
}
else {
priceAttributes = [
NSAttributedString.Key.font: AppFont.SFPro.semibold(size: 18)
]
}
let priceValue = NSAttributedString(string: price, attributes: priceAttributes)
let resultAttributes = [
NSAttributedString.Key.font: AppFont.SFPro.semibold(size: 18)
]
let result = NSMutableAttributedString(string: template, attributes: resultAttributes)
result.replaceCharacters(in: (result.string as NSString).range(of: "#PRICE#"), with: priceValue)
if let discountSubscription = self.discountSubscription {
guard let discountPrice = discountSubscription.localizedPrice else {
return NSAttributedString()
}
let discountPriceRange = (result.string as NSString).range(of: "#DISCOUNT_PRICE#")
guard discountPriceRange.location != NSNotFound else {
return NSAttributedString()
}
result.replaceCharacters(in: discountPriceRange, with: discountPrice)
}
if type == .yearly {
result.addAttribute(.foregroundColor,
value: ThemeManager.currentTheme.subscriptionPurchaseColor,
range: .init(location: 0, length: result.string.count))
self.setTitleColor(ThemeManager.currentTheme.subscriptionPurchaseColor, for: .normal)
}
else {
result.addAttribute(.foregroundColor,
value: ThemeManager.currentTheme.subscriptionPurchaseBackgroundColor,
range: .init(location: 0, length: result.string.count))
self.setTitleColor(ThemeManager.currentTheme.subscriptionPurchaseBackgroundColor, for: .normal)
}
return result
}
private var subscriptionToBuy: SKProduct {
discountSubscription ?? subscription
}
private var showUpgradeText: Bool {
discountSubscription != nil
}
@objc
private func handleTap() {
delegate?.button(self, triggeredPurchaseOf: subscriptionToBuy)
}
}
//
// SubscriptionTopView.swift
// 1Weather
//
// Created by Demid Merzlyakov on 10.09.2021.
//
import UIKit
import SnapKit
protocol SubscriptionTopViewDelegate: AnyObject {
func handleCloseButton()
}
class SubscriptionTopView: UIView {
private let oneWeatherLabel = UILabel()
private let premiumLabel = UILabel()
private let logoView = UIImageView()
private let closeButton = UIButton()
private var localizationObserver: LocalizationChangeObserver!
weak var delegate: SubscriptionTopViewDelegate?
public init() {
super.init(frame: .zero)
self.translatesAutoresizingMaskIntoConstraints = false
localizationObserver = LocalizationChangeObserver { [weak self] in
self?.updateButtonTexts()
}
backgroundColor = ThemeManager.currentTheme.subscriptionPurchaseBackgroundColor
oneWeatherLabel.font = AppFont.SFPro.bold(size: 32)
premiumLabel.font = AppFont.SFPro.bold(size: 24)
oneWeatherLabel.textColor = ThemeManager.currentTheme.subscriptionPurchaseColor
premiumLabel.textColor = ThemeManager.currentTheme.subscriptionPurchaseColor
updateButtonTexts()
logoView.image = UIImage(named: "subscription_logo")
logoView.contentMode = .scaleAspectFit
closeButton.addTarget(self, action: #selector(handleCloseButton), for: .touchUpInside)
closeButton.setImage(UIImage(named: "subscription_header_close"), for: .normal)
addSubview(logoView)
addSubview(oneWeatherLabel)
addSubview(premiumLabel)
addSubview(closeButton)
//Constraints
oneWeatherLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(28)
make.top.equalToSuperview().offset(81)
make.trailing.lessThanOrEqualTo(logoView.snp.leading).priority(.low)
}
premiumLabel.snp.makeConstraints { make in
make.top.equalTo(oneWeatherLabel.snp.bottom).offset(4)
make.leading.equalTo(oneWeatherLabel.snp.leading)
make.trailing.lessThanOrEqualTo(logoView.snp.leading).priority(.low)
}
logoView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(46)
make.trailing.equalToSuperview().offset(-18)
make.bottom.equalToSuperview().offset(-14)
make.width.equalTo(190)
make.height.equalTo(160)
}
closeButton.snp.makeConstraints { make in
make.width.height.equalTo(20)
make.top.equalToSuperview().inset(18)
make.right.equalToSuperview().inset(20)
}
}
private func updateButtonTexts() {
oneWeatherLabel.text = "subscription.header.oneWeather".localized()
premiumLabel.text = "subscription.header.premium".localized()
}
@objc private func handleCloseButton() {
delegate?.handleCloseButton()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
...@@ -62,6 +62,10 @@ class TodayCellFactory: CellFactory { ...@@ -62,6 +62,10 @@ class TodayCellFactory: CellFactory {
private var adViewCache = [IndexPath: AdView]() private var adViewCache = [IndexPath: AdView]()
private var featureAvailabilityManager: FeatureAvailabilityManager {
FeatureAvailabilityManager.shared
}
//Public //Public
public var delegate: CellFactoryDelegate? public var delegate: CellFactoryDelegate?
init(viewModel: TodayViewModel) { init(viewModel: TodayViewModel) {
...@@ -229,7 +233,7 @@ class TodayCellFactory: CellFactory { ...@@ -229,7 +233,7 @@ class TodayCellFactory: CellFactory {
private func setupHiddenRows() { private func setupHiddenRows() {
var rowsToHide = Set<TodayCellType>() var rowsToHide = Set<TodayCellType>()
if isAppPro() || !AdConfigManager.shared.adConfig.adsEnabled { if !featureAvailabilityManager.isAvailable(feature: .ads) {
rowsToHide.insert(.adBanner) rowsToHide.insert(.adBanner)
rowsToHide.insert(.adMREC) rowsToHide.insert(.adMREC)
} }
...@@ -239,7 +243,7 @@ class TodayCellFactory: CellFactory { ...@@ -239,7 +243,7 @@ class TodayCellFactory: CellFactory {
rowsToHide.insert(.alert) rowsToHide.insert(.alert)
} }
if location?.health?.airQuality == nil { if location?.health?.airQuality == nil || !featureAvailabilityManager.isAvailable(feature: .airQualityIndex) {
rowsToHide.insert(.airQuality) rowsToHide.insert(.airQuality)
} }
......
...@@ -10,9 +10,10 @@ import OneWeatherCore ...@@ -10,9 +10,10 @@ import OneWeatherCore
class TodayForecastTimePeriodCell: UITableViewCell { class TodayForecastTimePeriodCell: UITableViewCell {
//Private //Private
private let periodSegmentedControl = ForecastTimePeriodControl(items: ["forecast.timePeriod.daily".localized(), // private let periodSegmentedControl = ForecastTimePeriodControl(items: ["forecast.timePeriod.daily".localized(),
"forecast.timePeriod.hourly".localized(), // "forecast.timePeriod.hourly".localized(),
"forecast.timePeriod.minutely".localized()]) // "forecast.timePeriod.minutely".localized()])
private let periodSegmentedControl = ForecastTimePeriodControl(items: nil)
private let forecastTimePeriodView = ForecastTimePeriodView() private let forecastTimePeriodView = ForecastTimePeriodView()
private let minutelyForecastView = MinutelyForecastView() private let minutelyForecastView = MinutelyForecastView()
private let descriptionView = ForecastDescriptionView(lightStyleBackgroundColor: UIColor(hex: 0xfaedda).withAlphaComponent(0.5), private let descriptionView = ForecastDescriptionView(lightStyleBackgroundColor: UIColor(hex: 0xfaedda).withAlphaComponent(0.5),
...@@ -20,6 +21,7 @@ class TodayForecastTimePeriodCell: UITableViewCell { ...@@ -20,6 +21,7 @@ class TodayForecastTimePeriodCell: UITableViewCell {
UIColor(hex: 0xf71d11).withAlphaComponent(0).cgColor]) UIColor(hex: 0xf71d11).withAlphaComponent(0).cgColor])
private var location:Location? private var location:Location?
private var graphIsDrawn = false private var graphIsDrawn = false
private let featureAvailability = FeatureAvailabilityManager.shared
//MARK:- Cell life cycle //MARK:- Cell life cycle
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
...@@ -39,6 +41,18 @@ class TodayForecastTimePeriodCell: UITableViewCell { ...@@ -39,6 +41,18 @@ class TodayForecastTimePeriodCell: UITableViewCell {
//Public //Public
public func configure(with location: Location) { public func configure(with location: Location) {
self.location = location self.location = location
//Update segment control
if featureAvailability?.isAvailable(feature: .minutelyForecast) == true {
self.periodSegmentedControl.set(items: ["forecast.timePeriod.daily".localized(),
"forecast.timePeriod.hourly".localized(),
"forecast.timePeriod.minutely".localized()])
}
else {
self.periodSegmentedControl.set(items: ["forecast.timePeriod.daily".localized(),
"forecast.timePeriod.hourly".localized()])
}
self.forecastTimePeriodView.set(daily: location.daily, hourly: location.hourly) self.forecastTimePeriodView.set(daily: location.daily, hourly: location.hourly)
self.minutelyForecastView.configure(with: location, forecastType: .temperature) self.minutelyForecastView.configure(with: location, forecastType: .temperature)
self.handleSegmentDidChange() self.handleSegmentDidChange()
......
...@@ -62,7 +62,7 @@ class WidgetPromotionController: UIViewController { ...@@ -62,7 +62,7 @@ class WidgetPromotionController: UIViewController {
super.viewDidAppear(animated) super.viewDidAppear(animated)
if scrollView.frame.height > scrollView.contentSize.height { if scrollView.frame.height > scrollView.contentSize.height {
AppAnalytics.shared.log(event: .ANALYTICS_WIDGET_BOTTOM_SCROLLED) AppAnalytics.shared.log(event: .ANALYTICS_WIDGET_PROMO_BOTTOM_SCROLLED)
} }
} }
...@@ -134,6 +134,8 @@ class WidgetPromotionController: UIViewController { ...@@ -134,6 +134,8 @@ class WidgetPromotionController: UIViewController {
footerView.layer.shadowColor = UIColor(hex: 0xAAAAAA).cgColor footerView.layer.shadowColor = UIColor(hex: 0xAAAAAA).cgColor
} }
} }
var scrollViewObserver: NSKeyValueObservation?
} }
//MARK:- Prepare //MARK:- Prepare
...@@ -181,8 +183,18 @@ private extension WidgetPromotionController { ...@@ -181,8 +183,18 @@ private extension WidgetPromotionController {
} }
} }
func prepareScrollView() { func prepareScrollView() {
scrollView.delegate = self scrollViewObserver = scrollView.observe(\UIScrollView.contentOffset, options: .new) { [weak self] scrollView, valueChange in
guard let self = self else { return }
guard self.scrollView.contentSize.height > 10 && self.scrollView.frame.size.height > 10 else {
return
}
if self.scrollView.contentOffset.y >= self.scrollView.contentSize.height - self.scrollView.frame.height - 1 {
AppAnalytics.shared.log(event: .ANALYTICS_WIDGET_PROMO_BOTTOM_SCROLLED)
self.scrollViewObserver = nil
}
}
view.addSubview(scrollView) view.addSubview(scrollView)
scrollView.snp.makeConstraints { make in scrollView.snp.makeConstraints { make in
make.left.right.equalToSuperview() make.left.right.equalToSuperview()
...@@ -254,17 +266,6 @@ extension WidgetPromotionController: UIViewControllerTransitioningDelegate { ...@@ -254,17 +266,6 @@ extension WidgetPromotionController: UIViewControllerTransitioningDelegate {
} }
} }
//MARK:- UIScrollView Delegate
@available(iOS 14, *)
extension WidgetPromotionController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView.contentOffset.y == scrollView.contentSize.height - scrollView.frame.height {
AppAnalytics.shared.log(event: .ANALYTICS_WIDGET_BOTTOM_SCROLLED)
}
}
}
//MARK:- UIGesture Delegate //MARK:- UIGesture Delegate
@available(iOS 14, *) @available(iOS 14, *)
extension WidgetPromotionController: UIGestureRecognizerDelegate { extension WidgetPromotionController: UIGestureRecognizerDelegate {
......
...@@ -33,16 +33,19 @@ class ForecastViewModel: ViewModelProtocol { ...@@ -33,16 +33,19 @@ class ForecastViewModel: ViewModelProtocol {
//Private //Private
private var locationManager: LocationManager private var locationManager: LocationManager
private let storeManager: StoreManager
deinit { deinit {
Settings.shared.delegate.remove(delegate: self) Settings.shared.delegate.remove(delegate: self)
self.locationManager.remove(delegate: self) self.locationManager.remove(delegate: self)
} }
public init(locationManager: LocationManager) { public init(locationManager: LocationManager, storeManager: StoreManager = StoreManager.shared) {
self.locationManager = locationManager self.locationManager = locationManager
self.storeManager = storeManager
locationManager.add(delegate: self) locationManager.add(delegate: self)
Settings.shared.delegate.add(delegate: self) Settings.shared.delegate.add(delegate: self)
self.storeManager.add(observer: self)
} }
public func updateWeather() { public func updateWeather() {
...@@ -96,3 +99,11 @@ extension ForecastViewModel: SettingsDelegate { ...@@ -96,3 +99,11 @@ extension ForecastViewModel: SettingsDelegate {
} }
} }
} }
extension ForecastViewModel: StoreManagerObserver {
func storeManagerUpdatedStatus(_ storeManager: StoreManager) {
onMain {
self.delegate?.viewModelDidChange(model: self)
}
}
}
...@@ -22,8 +22,15 @@ class MenuViewModel: NSObject, ViewModelProtocol { ...@@ -22,8 +22,15 @@ class MenuViewModel: NSObject, ViewModelProtocol {
private var proVersionPurchaseInProgress = false private var proVersionPurchaseInProgress = false
private let log = Logger(componentName: "MenuViewModel") private let log = Logger(componentName: "MenuViewModel")
private var upgradeAlert: UIAlertController? private var upgradeAlert: UIAlertController?
public let storeManager: StoreManager
public weak var delegate: MenuViewModelDelegate? public weak var delegate: MenuViewModelDelegate?
public init(storeManager: StoreManager = StoreManager.shared) {
self.storeManager = storeManager
super.init()
self.storeManager.add(observer: self)
}
public func showAlert(_ title: String?, message: String?) -> Void { public func showAlert(_ title: String?, message: String?) -> Void {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
...@@ -62,6 +69,30 @@ class MenuViewModel: NSObject, ViewModelProtocol { ...@@ -62,6 +69,30 @@ class MenuViewModel: NSObject, ViewModelProtocol {
} }
} }
public func restorePurchases() {
PKHUD.sharedHUD.contentView = PKHUDProgressView()
PKHUD.sharedHUD.show()
storeManager.restoreInApp { error in
let alertMessage: String
if let errorMessage = error {
alertMessage = errorMessage
}
else {
alertMessage = "subscription.restore.success".localized()
}
onMain {
PKHUD.sharedHUD.hide(animated: false)
let alert = UIAlertController(title: "menu.restorePurchases".localized(),
message: alertMessage,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "general.cancel".localized(), style: .cancel))
self.delegate?.viewControllerForPresentation().present(alert, animated: true)
}
}
}
public func viewAboutUs() { public func viewAboutUs() {
self.delegate?.presentWebView(url: ONE_WEATHER_ABOUT_US_URL) self.delegate?.presentWebView(url: ONE_WEATHER_ABOUT_US_URL)
analytics(log: .ANALYTICS_VIEW_ABOUT) analytics(log: .ANALYTICS_VIEW_ABOUT)
...@@ -96,29 +127,11 @@ class MenuViewModel: NSObject, ViewModelProtocol { ...@@ -96,29 +127,11 @@ class MenuViewModel: NSObject, ViewModelProtocol {
self.delegate?.viewControllerForPresentation().present(alert, animated: true) self.delegate?.viewControllerForPresentation().present(alert, animated: true)
} }
private let kOLAppMetricsCountKey: String = "count" private let kOLAppMetricsCountKey: String = "count"
private let kOLAppMetricsDateKey: String = "date" private let kOLAppMetricsDateKey: String = "date"
private func logEventToUserDefaults(forKey eventKey: String) {
let userDefaults = UserDefaults.standard
var updatedCount: Int = 1
var metricsLog = userDefaults.dictionary(forKey: kOLAppMetricsKey) ?? [String : Any]()
if let countEvent = metricsLog[eventKey] as? [String : Any], let count = countEvent[kOLAppMetricsCountKey] as? Int {
updatedCount = count + 1
}
let updateEvent = [kOLAppMetricsCountKey: updatedCount, kOLAppMetricsDateKey: Date()] as [String : Any]
metricsLog[eventKey] = updateEvent
userDefaults.set(metricsLog, forKey: kOLAppMetricsKey)
}
private func updateToProCompleted() { private func updateToProCompleted() {
logEventToUserDefaults(forKey: kEventInAppPurchasedCompleted) storeManager.removeAdsPurchased = true
NotificationCenter.default.post(name: Notification.Name(rawValue: kEventInAppPurchasedCompleted), object: nil)
analytics(log: .ANALYTICS_GO_PRO) analytics(log: .ANALYTICS_GO_PRO)
delegate?.viewModelDidChange(model: self) delegate?.viewModelDidChange(model: self)
} }
...@@ -126,9 +139,13 @@ class MenuViewModel: NSObject, ViewModelProtocol { ...@@ -126,9 +139,13 @@ class MenuViewModel: NSObject, ViewModelProtocol {
// MARK: - Help section // MARK: - Help section
extension MenuViewModel { extension MenuViewModel {
public var featureAvailabilityManager: FeatureAvailabilityManager {
FeatureAvailabilityManager.shared
}
private func helpRequestBodyString() -> String { private func helpRequestBodyString() -> String {
var str = String() var str = String()
str.append("Weather Alerts Enabled: \(ConfigManager.shared.config.nwsAlertsViaMoEngageEnabled)\n") str.append("Weather Alerts Enabled: \(featureAvailabilityManager.isAvailable(feature: .nwsAlertsViaMoEngage))\n")
let isRegistered = UIApplication.shared.isRegisteredForRemoteNotifications let isRegistered = UIApplication.shared.isRegisteredForRemoteNotifications
str.append("Push Enabled: \(isRegistered)\n") str.append("Push Enabled: \(isRegistered)\n")
...@@ -171,7 +188,7 @@ extension MenuViewModel { ...@@ -171,7 +188,7 @@ extension MenuViewModel {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString")! let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString")!
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion")! let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion")!
let systemVersion = UIDevice.current.systemVersion let systemVersion = UIDevice.current.systemVersion
let isPro = isAppPro() ? "Yes" : "No" let isPro = storeManager.removeAdsPurchased ? "Yes" : "No"
let installedDate = "Unknown" let installedDate = "Unknown"
let deviceInfo = "Version: \(version)-\(build)\nDevice: \(deviceName)\nOS: \(systemVersion)\nPro: \(isPro) \nInstalled: \(installedDate) \(deviceToken)" let deviceInfo = "Version: \(version)-\(build)\nDevice: \(deviceName)\nOS: \(systemVersion)\nPro: \(isPro) \nInstalled: \(installedDate) \(deviceToken)"
...@@ -288,3 +305,11 @@ extension MenuViewModel: OLInAppStoreManagerUIDelegate { ...@@ -288,3 +305,11 @@ extension MenuViewModel: OLInAppStoreManagerUIDelegate {
return delegate.viewControllerForPresentation() return delegate.viewControllerForPresentation()
} }
} }
extension MenuViewModel: StoreManagerObserver {
func storeManagerUpdatedStatus(_ storeManager: StoreManager) {
onMain {
self.delegate?.viewModelDidChange(model: self)
}
}
}
...@@ -13,18 +13,25 @@ class NWSAlertViewModel: ViewModelProtocol { ...@@ -13,18 +13,25 @@ class NWSAlertViewModel: ViewModelProtocol {
public private(set) var alert: NWSAlert public private(set) var alert: NWSAlert
private let alertsManager: NWSAlertsManager private let alertsManager: NWSAlertsManager
public var cellFactory: NWSAlertCellFactory public var cellFactory: NWSAlertCellFactory
private let featureAvailabilityManager: FeatureAvailabilityManager
private let storeManager: StoreManager
deinit { deinit {
alertsManager.delegates.remove(delegate: self) alertsManager.delegates.remove(delegate: self)
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
public init(alert: NWSAlert, alertsManager: NWSAlertsManager = LocationManager.shared.nwsAlertsManager) { public init(alert: NWSAlert,
alertsManager: NWSAlertsManager = LocationManager.shared.nwsAlertsManager,
featureAvailabilityManager: FeatureAvailabilityManager = FeatureAvailabilityManager.shared,
storeManager: StoreManager = StoreManager.shared) {
self.alert = alert self.alert = alert
self.alertsManager = alertsManager self.alertsManager = alertsManager
cellFactory = NWSAlertCellFactory(alert: alert) cellFactory = NWSAlertCellFactory(alert: alert)
self.featureAvailabilityManager = featureAvailabilityManager
self.storeManager = storeManager
alertsManager.delegates.add(delegate: self) alertsManager.delegates.add(delegate: self)
NotificationCenter.default.addObserver(self, selector: #selector(handlePremiumStateChange), name: Notification.Name(rawValue: kEventInAppPurchasedCompleted), object: nil) self.storeManager.add(observer: self)
cellFactory.delegate = self cellFactory.delegate = self
} }
...@@ -57,3 +64,9 @@ extension NWSAlertViewModel: CellFactoryDelegate { ...@@ -57,3 +64,9 @@ extension NWSAlertViewModel: CellFactoryDelegate {
} }
} }
extension NWSAlertViewModel: StoreManagerObserver {
func storeManagerUpdatedStatus(_ storeManager: StoreManager) {
handlePremiumStateChange()
}
}
//
// SubscriptionViewModel.swift
// 1Weather
//
// Created by Demid Merzlyakov on 10.09.2021.
//
import Foundation
import OneWeatherCore
import StoreKit
protocol SubscriptionViewModelDelegate: ViewModelDelegate {
func viewModel(_ vm: SubscriptionViewModel, finishedSubscriptionPurchaseWithResult result: Bool)
}
class SubscriptionViewModel: ViewModelProtocol {
public weak var delegate: SubscriptionViewModelDelegate?
public let storeManager: StoreManager
public init(storeManager: StoreManager) {
self.storeManager = storeManager
}
public var isProUser: Bool {
storeManager.removeAdsPurchased
}
public func purchase(subscription: SKProduct) {
onMain {
self.storeManager.purchase(product: subscription) { success in
onMain {
self.delegate?.viewModel(self, finishedSubscriptionPurchaseWithResult: success)
}
}
}
}
}
...@@ -23,6 +23,7 @@ class TodayViewModel: ViewModelProtocol { ...@@ -23,6 +23,7 @@ class TodayViewModel: ViewModelProtocol {
//Private //Private
private let locationManager: LocationManager private let locationManager: LocationManager
private let configManager: ConfigManager private let configManager: ConfigManager
private let storeManager: StoreManager
private let kAnalyticsThresholdSec = 2.5 private let kAnalyticsThresholdSec = 2.5
private let log = Logger(componentName: "TodayViewModel") private let log = Logger(componentName: "TodayViewModel")
private var ccpaHelper = CCPAHelper.shared private var ccpaHelper = CCPAHelper.shared
...@@ -35,6 +36,10 @@ class TodayViewModel: ViewModelProtocol { ...@@ -35,6 +36,10 @@ class TodayViewModel: ViewModelProtocol {
shortsManager.shorts shortsManager.shorts
} }
private var featureAvailabilityManager: FeatureAvailabilityManager {
FeatureAvailabilityManager.shared
}
public lazy var todayCellFactory:TodayCellFactory = { public lazy var todayCellFactory:TodayCellFactory = {
let factory = TodayCellFactory(viewModel: self) let factory = TodayCellFactory(viewModel: self)
factory.delegate = self factory.delegate = self
...@@ -47,15 +52,16 @@ class TodayViewModel: ViewModelProtocol { ...@@ -47,15 +52,16 @@ class TodayViewModel: ViewModelProtocol {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
public init(locationManager: LocationManager = LocationManager.shared, configManager: ConfigManager = ConfigManager.shared, shortsManager: ShortsManager = ShortsManager.shared) { public init(locationManager: LocationManager = LocationManager.shared, configManager: ConfigManager = ConfigManager.shared, shortsManager: ShortsManager = ShortsManager.shared, storeManager: StoreManager = StoreManager.shared) {
self.locationManager = locationManager self.locationManager = locationManager
self.configManager = configManager self.configManager = configManager
self.shortsManager = shortsManager self.shortsManager = shortsManager
self.storeManager = storeManager
self.storeManager.add(observer: self)
self.shortsManager.multicastDelegate.add(delegate: self) self.shortsManager.multicastDelegate.add(delegate: self)
self.location = locationManager.selectedLocation self.location = locationManager.selectedLocation
locationManager.add(delegate: self) locationManager.add(delegate: self)
Settings.shared.delegate.add(delegate: self) Settings.shared.delegate.add(delegate: self)
NotificationCenter.default.addObserver(self, selector: #selector(handlePremiumStateChange), name: Notification.Name(rawValue: kEventInAppPurchasedCompleted), object: nil)
} }
//MARK: Private //MARK: Private
...@@ -73,7 +79,7 @@ class TodayViewModel: ViewModelProtocol { ...@@ -73,7 +79,7 @@ class TodayViewModel: ViewModelProtocol {
guard !adManager.initialized else { return } guard !adManager.initialized else { return }
self.ccpaHelper.updateCCPAStatus(reason: "initialization") self.ccpaHelper.updateCCPAStatus(reason: "initialization")
if !isAppPro() { if self.featureAvailabilityManager.isAvailable(feature: .ads) {
// In Debug mode we allow the user to change the environment // In Debug mode we allow the user to change the environment
//TODO AdStack: add MoPub App Key //TODO AdStack: add MoPub App Key
...@@ -127,7 +133,7 @@ class TodayViewModel: ViewModelProtocol { ...@@ -127,7 +133,7 @@ class TodayViewModel: ViewModelProtocol {
// not calling onboardingFlowCompleted, because it will be called in the ATT prompt completion handler. // not calling onboardingFlowCompleted, because it will be called in the ATT prompt completion handler.
return return
} }
if self.configManager.config.showAttPrompt if self.featureAvailabilityManager.isAvailable(feature: .attPrompt)
&& !self.ccpaHelper.isNewUser && !self.ccpaHelper.isNewUser
&& ATTrackingManager.trackingAuthorizationStatus == .notDetermined { && ATTrackingManager.trackingAuthorizationStatus == .notDetermined {
...@@ -191,6 +197,12 @@ extension TodayViewModel: LocationManagerDelegate { ...@@ -191,6 +197,12 @@ extension TodayViewModel: LocationManagerDelegate {
} }
} }
extension TodayViewModel: StoreManagerObserver {
func storeManagerUpdatedStatus(_ storeManager: StoreManager) {
handlePremiumStateChange()
}
}
//MARK:- Settings Delegate //MARK:- Settings Delegate
extension TodayViewModel: SettingsDelegate { extension TodayViewModel: SettingsDelegate {
func settingsDidChange() { func settingsDidChange() {
......
...@@ -180,7 +180,7 @@ ...@@ -180,7 +180,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastSwiftUpdateCheck = 1250; LastSwiftUpdateCheck = 1250;
LastUpgradeCheck = 1250; LastUpgradeCheck = 1300;
TargetAttributes = { TargetAttributes = {
CD3883CD2657B7890070FD6F = { CD3883CD2657B7890070FD6F = {
CreatedOnToolsVersion = 12.5; CreatedOnToolsVersion = 12.5;
......
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD3883CD2657B7890070FD6F"
BuildableName = "BlendFIPSSource.framework"
BlueprintName = "BlendFIPSSource"
ReferencedContainer = "container:BlendFIPSSource.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD3883D62657B78A0070FD6F"
BuildableName = "BlendFIPSSourceTests.xctest"
BlueprintName = "BlendFIPSSourceTests"
ReferencedContainer = "container:BlendFIPSSource.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD3883CD2657B7890070FD6F"
BuildableName = "BlendFIPSSource.framework"
BlueprintName = "BlendFIPSSource"
ReferencedContainer = "container:BlendFIPSSource.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
...@@ -191,7 +191,7 @@ ...@@ -191,7 +191,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastSwiftUpdateCheck = 1250; LastSwiftUpdateCheck = 1250;
LastUpgradeCheck = 1250; LastUpgradeCheck = 1300;
TargetAttributes = { TargetAttributes = {
CD3883A12657B5EF0070FD6F = { CD3883A12657B5EF0070FD6F = {
CreatedOnToolsVersion = 12.5; CreatedOnToolsVersion = 12.5;
......
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD3883A12657B5EF0070FD6F"
BuildableName = "BlendHealthSource.framework"
BlueprintName = "BlendHealthSource"
ReferencedContainer = "container:BlendHealthSource.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD3883AA2657B5EF0070FD6F"
BuildableName = "BlendHealthSourceTests.xctest"
BlueprintName = "BlendHealthSourceTests"
ReferencedContainer = "container:BlendHealthSource.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD3883A12657B5EF0070FD6F"
BuildableName = "BlendHealthSource.framework"
BlueprintName = "BlendHealthSource"
ReferencedContainer = "container:BlendHealthSource.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
...@@ -36,6 +36,7 @@ ...@@ -36,6 +36,7 @@
CD3884522657BA550070FD6F /* _CoreNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3884322657BA410070FD6F /* _CoreNotifications.swift */; }; CD3884522657BA550070FD6F /* _CoreNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3884322657BA410070FD6F /* _CoreNotifications.swift */; };
CD3884532657BA550070FD6F /* _CoreNWSAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3884332657BA410070FD6F /* _CoreNWSAlert.swift */; }; CD3884532657BA550070FD6F /* _CoreNWSAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3884332657BA410070FD6F /* _CoreNWSAlert.swift */; };
CD69DBC22666381500FD2A7C /* OneWeatherAnalytics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD69DBC12666381500FD2A7C /* OneWeatherAnalytics.framework */; }; CD69DBC22666381500FD2A7C /* OneWeatherAnalytics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD69DBC12666381500FD2A7C /* OneWeatherAnalytics.framework */; };
CD8B861826F9C5EA00E3A9CD /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD8B861726F9C5EA00E3A9CD /* CoreLocation.framework */; };
CD8EA914265D42E2000D3D63 /* 1WModel.xcdatamodeld in Resources */ = {isa = PBXBuildFile; fileRef = CD3884372657BA420070FD6F /* 1WModel.xcdatamodeld */; }; CD8EA914265D42E2000D3D63 /* 1WModel.xcdatamodeld in Resources */ = {isa = PBXBuildFile; fileRef = CD3884372657BA420070FD6F /* 1WModel.xcdatamodeld */; };
CE0E006C26C739680060CBB6 /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0E006B26C739680060CBB6 /* CoreDataStack.swift */; }; CE0E006C26C739680060CBB6 /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0E006B26C739680060CBB6 /* CoreDataStack.swift */; };
CEC40AF42705A2BF00C98305 /* _CoreMinutelyForecast.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC40AF22705A2BE00C98305 /* _CoreMinutelyForecast.swift */; }; CEC40AF42705A2BF00C98305 /* _CoreMinutelyForecast.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC40AF22705A2BE00C98305 /* _CoreMinutelyForecast.swift */; };
...@@ -91,6 +92,7 @@ ...@@ -91,6 +92,7 @@
CD3884392657BA420070FD6F /* regenerate_objects.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = regenerate_objects.sh; sourceTree = "<group>"; }; CD3884392657BA420070FD6F /* regenerate_objects.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = regenerate_objects.sh; sourceTree = "<group>"; };
CD38843A2657BA420070FD6F /* CoreDataStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStorage.swift; sourceTree = "<group>"; }; CD38843A2657BA420070FD6F /* CoreDataStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStorage.swift; sourceTree = "<group>"; };
CD69DBC12666381500FD2A7C /* OneWeatherAnalytics.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OneWeatherAnalytics.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CD69DBC12666381500FD2A7C /* OneWeatherAnalytics.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OneWeatherAnalytics.framework; sourceTree = BUILT_PRODUCTS_DIR; };
CD8B861726F9C5EA00E3A9CD /* CoreLocation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreLocation.framework; path = System/Library/Frameworks/CoreLocation.framework; sourceTree = SDKROOT; };
CE0E006B26C739680060CBB6 /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = "<group>"; }; CE0E006B26C739680060CBB6 /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = "<group>"; };
CEC40AF1270587F600C98305 /* 1.2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = 1.2.xcdatamodel; sourceTree = "<group>"; }; CEC40AF1270587F600C98305 /* 1.2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = 1.2.xcdatamodel; sourceTree = "<group>"; };
CEC40AF22705A2BE00C98305 /* _CoreMinutelyForecast.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _CoreMinutelyForecast.swift; sourceTree = "<group>"; }; CEC40AF22705A2BE00C98305 /* _CoreMinutelyForecast.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _CoreMinutelyForecast.swift; sourceTree = "<group>"; };
...@@ -106,6 +108,7 @@ ...@@ -106,6 +108,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
CD8B861826F9C5EA00E3A9CD /* CoreLocation.framework in Frameworks */,
CEEF40F9265E2EE600425D8F /* OneWeatherCore.framework in Frameworks */, CEEF40F9265E2EE600425D8F /* OneWeatherCore.framework in Frameworks */,
CD69DBC22666381500FD2A7C /* OneWeatherAnalytics.framework in Frameworks */, CD69DBC22666381500FD2A7C /* OneWeatherAnalytics.framework in Frameworks */,
0732751CD0F7F1FDA514D5A4 /* Pods_CoreDataStorage.framework in Frameworks */, 0732751CD0F7F1FDA514D5A4 /* Pods_CoreDataStorage.framework in Frameworks */,
...@@ -219,6 +222,7 @@ ...@@ -219,6 +222,7 @@
CEEF40F7265E2EE600425D8F /* Frameworks */ = { CEEF40F7265E2EE600425D8F /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
CD8B861726F9C5EA00E3A9CD /* CoreLocation.framework */,
CD69DBC12666381500FD2A7C /* OneWeatherAnalytics.framework */, CD69DBC12666381500FD2A7C /* OneWeatherAnalytics.framework */,
CEEF40F8265E2EE600425D8F /* OneWeatherCore.framework */, CEEF40F8265E2EE600425D8F /* OneWeatherCore.framework */,
8A4DE7322658C61DE69C4445 /* Pods_CoreDataStorage.framework */, 8A4DE7322658C61DE69C4445 /* Pods_CoreDataStorage.framework */,
...@@ -294,7 +298,7 @@ ...@@ -294,7 +298,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastSwiftUpdateCheck = 1250; LastSwiftUpdateCheck = 1250;
LastUpgradeCheck = 1250; LastUpgradeCheck = 1300;
TargetAttributes = { TargetAttributes = {
CD3884022657BA190070FD6F = { CD3884022657BA190070FD6F = {
CreatedOnToolsVersion = 12.5; CreatedOnToolsVersion = 12.5;
......
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD3884022657BA190070FD6F"
BuildableName = "CoreDataStorage.framework"
BlueprintName = "CoreDataStorage"
ReferencedContainer = "container:CoreDataStorage.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD38840B2657BA190070FD6F"
BuildableName = "CoreDataStorageTests.xctest"
BlueprintName = "CoreDataStorageTests"
ReferencedContainer = "container:CoreDataStorage.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD3884022657BA190070FD6F"
BuildableName = "CoreDataStorage.framework"
BlueprintName = "CoreDataStorage"
ReferencedContainer = "container:CoreDataStorage.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
...@@ -12,6 +12,7 @@ class CoreDataStack { ...@@ -12,6 +12,7 @@ class CoreDataStack {
private let log = Logger(componentName: "CoreDataStorage 💾") private let log = Logger(componentName: "CoreDataStorage 💾")
private let modelName = "1WModel" private let modelName = "1WModel"
private lazy var persistentContainer: NSPersistentContainer = { private lazy var persistentContainer: NSPersistentContainer = {
log.verbose("Make persistant container")
var container: NSPersistentContainer var container: NSPersistentContainer
if if
let modelURL = Bundle(for: CoreDataStorage.self).url(forResource: "1WModel", withExtension: "momd"), let modelURL = Bundle(for: CoreDataStorage.self).url(forResource: "1WModel", withExtension: "momd"),
...@@ -42,55 +43,141 @@ class CoreDataStack { ...@@ -42,55 +43,141 @@ class CoreDataStack {
return containerUrl.appendingPathComponent("\(modelName).sqlite") return containerUrl.appendingPathComponent("\(modelName).sqlite")
} }
private func isInWidget() -> Bool {
guard let extesion = Bundle.main.infoDictionary?["NSExtension"] as? [String: String] else { return false }
guard let widget = extesion["NSExtensionPointIdentifier"] else { return false }
return widget == "com.apple.widgetkit-extension"
}
/// The Persistent Store used to be in the app directory, but was then moved to an app group container to be shared with widgets. /// The Persistent Store used to be in the app directory, but was then moved to an app group container to be shared with widgets.
/// For all users who still have it in the old location, we need to move it to the new one. /// For all users who still have it in the old location, we need to move it to the new one.
private func movePersistentStoreIfNeeded() { private func movePersistentStoreIfNeeded() {
log.verbose("Move store: start")
let legacyStoreUrl = NSPersistentContainer.defaultDirectoryURL().appendingPathComponent("\(modelName).sqlite") let legacyStoreUrl = NSPersistentContainer.defaultDirectoryURL().appendingPathComponent("\(modelName).sqlite")
guard let storeUrl = self.storeUrl else { guard let storeUrl = self.storeUrl else {
return return
} }
let fileManager = FileManager.default let fileManager = FileManager.default
log.verbose("\nLegacy path: \(legacyStoreUrl.path)\nNew path: \(storeUrl.path)")
log.verbose("Move store: will move: \(fileManager.fileExists(atPath: legacyStoreUrl.path) && !fileManager.fileExists(atPath: storeUrl.path)) old exists: \(fileManager.fileExists(atPath: legacyStoreUrl.path)) new exists: \(fileManager.fileExists(atPath: storeUrl.path))")
guard fileManager.fileExists(atPath: legacyStoreUrl.path) && !fileManager.fileExists(atPath: storeUrl.path) else { guard fileManager.fileExists(atPath: legacyStoreUrl.path) && !fileManager.fileExists(atPath: storeUrl.path) else {
log.verbose("Move store: won't do. return.")
return return
} }
do { do {
log.info("Moving the persistent store to the app group location.") log.info("Moving the persistent store to the app group location.")
try fileManager.moveItem(at: legacyStoreUrl, to: storeUrl) try fileManager.moveItem(at: legacyStoreUrl, to: storeUrl)
let additionalFileSuffixes = ["-wal", "-shm"]
for additionalSuffix in additionalFileSuffixes {
let oldPath = legacyStoreUrl.path + additionalSuffix
if fileManager.fileExists(atPath: oldPath) {
let newPath = storeUrl.path + additionalSuffix
log.debug("Move \(oldPath)")
try fileManager.moveItem(atPath: oldPath, toPath: newPath)
}
}
} }
catch { catch {
log.error("Error moving the model from the old location to the new one: \(error)") log.error("Error moving the model from the old location to the new one: \(error)")
} }
log.verbose("Move store: done")
} }
public func getManagedObjectContext(_ completion: @escaping (NSManagedObjectContext?) -> ()) { public func getManagedObjectContext(_ completion: @escaping (NSManagedObjectContext?) -> ()) {
log.verbose("get context: start")
if let existingContext = self.managedContext { if let existingContext = self.managedContext {
log.verbose("get context: got existing")
completion(existingContext) completion(existingContext)
return return
} }
else { else {
initializationSemaphore.wait() log.verbose("get context: no existing")
defer { guard shouldInit() else {
initializationSemaphore.signal() log.verbose("get context: NIL (shouldn't init)")
completion(nil)
return
}
if isInWidget() {
log.verbose("get context: WIDGET. Start wait")
initializationSemaphore.wait()
log.verbose("get context: WIDGET. Resume wait")
if !initializing {
log.debug("get context: WIDGET. Initialize stack...")
initializationSemaphore.signal()
initializeStack()
}
else {
log.verbose("get context: WIDGET. Signal!")
initializationSemaphore.signal()
}
} }
log.verbose("get context: start wait")
initializationSemaphore.wait()
log.verbose("get context: resume wait")
if self.managedContext == nil { if self.managedContext == nil {
self.managedContext = self.persistentContainer.newBackgroundContext() self.managedContext = self.persistentContainer.newBackgroundContext()
log.verbose("get context: initialized (\(self.managedContext == nil ? "fail" : "success"))")
} }
log.verbose("get context: SIGNAL")
initializationSemaphore.signal()
log.verbose("get context: completion")
completion(self.managedContext) completion(self.managedContext)
} }
} }
public init() {
private func shouldInit() -> Bool {
log.verbose("should init: start")
guard isInWidget() else {
log.verbose("should init: true (app)")
return true
}
guard let storeUrl = self.storeUrl else {
log.verbose("should init: false (no url)")
return false
}
let fileManager = FileManager.default
let storeExists = fileManager.fileExists(atPath: storeUrl.path)
log.verbose("should init: \(storeExists) (store exists)")
return storeExists
}
private var initializing = false
private func initializeStack() {
let log = self.log
log.verbose("Initialize stack (start wait)")
initializationSemaphore.wait() initializationSemaphore.wait()
log.verbose("Initialize stack (resume wait)")
initializing = true
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
log.verbose("Initialize stack: async start")
self.movePersistentStoreIfNeeded() self.movePersistentStoreIfNeeded()
log.verbose("Initialize stack: load stores")
self.persistentContainer.loadPersistentStores { [weak self] (description, error) in self.persistentContainer.loadPersistentStores { [weak self] (description, error) in
defer { if self == nil {
self?.initializationSemaphore.signal() log.verbose("Initialize stack: SELF IS NIL")
} }
log.verbose("Initialize stack: signal")
self?.initializationSemaphore.signal()
log.verbose("Initialize stack: signal (done)")
if let error = error { if let error = error {
self?.log.error("Error loading persistent stores: \(error)") log.error("Error loading persistent stores: \(error)")
} }
} }
} }
} }
public init() {
log.verbose("Init")
if shouldInit() {
initializeStack()
}
}
deinit {
log.verbose("Deinit")
}
} }
...@@ -57,11 +57,12 @@ public class CoreDataStorage: Storage { ...@@ -57,11 +57,12 @@ public class CoreDataStorage: Storage {
public func load(completion: @escaping StorageCompletion) { public func load(completion: @escaping StorageCompletion) {
log.info("Load: start") log.info("Load: start")
stack.getManagedObjectContext { [weak self] context in stack.getManagedObjectContext { [weak self] context in
context?.perform { guard let context = context else {
completion([], nil, nil)
return
}
context.perform {
guard let self = self else { return } guard let self = self else { return }
guard let context = context else {
return
}
let completionWithErrorHandling: StorageCompletion = { [weak self] (locations, selectedIndex, error) in let completionWithErrorHandling: StorageCompletion = { [weak self] (locations, selectedIndex, error) in
if error != nil { if error != nil {
self?.log.error("Load: error.") self?.log.error("Load: error.")
......
...@@ -180,7 +180,7 @@ ...@@ -180,7 +180,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastSwiftUpdateCheck = 1250; LastSwiftUpdateCheck = 1250;
LastUpgradeCheck = 1250; LastUpgradeCheck = 1300;
TargetAttributes = { TargetAttributes = {
CD3884602657BB380070FD6F = { CD3884602657BB380070FD6F = {
CreatedOnToolsVersion = 12.5; CreatedOnToolsVersion = 12.5;
......
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD3884602657BB380070FD6F"
BuildableName = "DelayedSaveStorage.framework"
BlueprintName = "DelayedSaveStorage"
ReferencedContainer = "container:DelayedSaveStorage.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD3884692657BB380070FD6F"
BuildableName = "DelayedSaveStorageTests.xctest"
BlueprintName = "DelayedSaveStorageTests"
ReferencedContainer = "container:DelayedSaveStorage.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD3884602657BB380070FD6F"
BuildableName = "DelayedSaveStorage.framework"
BlueprintName = "DelayedSaveStorage"
ReferencedContainer = "container:DelayedSaveStorage.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
...@@ -196,7 +196,7 @@ ...@@ -196,7 +196,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastSwiftUpdateCheck = 1250; LastSwiftUpdateCheck = 1250;
LastUpgradeCheck = 1250; LastUpgradeCheck = 1300;
TargetAttributes = { TargetAttributes = {
CDFE3F07266E407A00E72910 = { CDFE3F07266E407A00E72910 = {
CreatedOnToolsVersion = 12.5; CreatedOnToolsVersion = 12.5;
......
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CDFE3F07266E407A00E72910"
BuildableName = "InMobiShortsSource.framework"
BlueprintName = "InMobiShortsSource"
ReferencedContainer = "container:InMobiShortsSource.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CDFE3F10266E407A00E72910"
BuildableName = "InMobiShortsSourceTests.xctest"
BlueprintName = "InMobiShortsSourceTests"
ReferencedContainer = "container:InMobiShortsSource.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CDFE3F07266E407A00E72910"
BuildableName = "InMobiShortsSource.framework"
BlueprintName = "InMobiShortsSource"
ReferencedContainer = "container:InMobiShortsSource.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 51; objectVersion = 50;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
...@@ -209,7 +209,7 @@ ...@@ -209,7 +209,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastSwiftUpdateCheck = 1250; LastSwiftUpdateCheck = 1250;
LastUpgradeCheck = 1250; LastUpgradeCheck = 1300;
TargetAttributes = { TargetAttributes = {
CDD2F900266511BF00B48322 = { CDD2F900266511BF00B48322 = {
CreatedOnToolsVersion = 12.5; CreatedOnToolsVersion = 12.5;
......
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CDD2F900266511BF00B48322"
BuildableName = "OneWeatherAnalytics.framework"
BlueprintName = "OneWeatherAnalytics"
ReferencedContainer = "container:OneWeatherAnalytics.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CDD2F909266511C000B48322"
BuildableName = "OneWeatherAnalyticsTests.xctest"
BlueprintName = "OneWeatherAnalyticsTests"
ReferencedContainer = "container:OneWeatherAnalytics.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CDD2F900266511BF00B48322"
BuildableName = "OneWeatherAnalytics.framework"
BlueprintName = "OneWeatherAnalytics"
ReferencedContainer = "container:OneWeatherAnalytics.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
...@@ -92,7 +92,7 @@ public enum AnalyticsEvent: String { ...@@ -92,7 +92,7 @@ public enum AnalyticsEvent: String {
case ANALYTICS_WIDGET_PROMO_SEEN = "WIDGET_PROMO_SEEN" case ANALYTICS_WIDGET_PROMO_SEEN = "WIDGET_PROMO_SEEN"
case ANALYTICS_WIDGET_PROMO_DISMISS = "WIDGET_PROMO_DISMISS" case ANALYTICS_WIDGET_PROMO_DISMISS = "WIDGET_PROMO_DISMISS"
case ANALYTICS_WIDGET_PROMO_EXPAND = "WIDGET_PROMO_EXPAND" case ANALYTICS_WIDGET_PROMO_EXPAND = "WIDGET_PROMO_EXPAND"
case ANALYTICS_WIDGET_BOTTOM_SCROLLED = "WIDGET_PROMO_BOTTOM_SCROLLED" case ANALYTICS_WIDGET_PROMO_BOTTOM_SCROLLED = "WIDGET_PROMO_BOTTOM_SCROLLED"
case ANALYTICS_WIDGET_PROMO_LEARN_CTA = "WIDGET_PROMO_LEARN_CTA" case ANALYTICS_WIDGET_PROMO_LEARN_CTA = "WIDGET_PROMO_LEARN_CTA"
///Widget actions ///Widget actions
...@@ -135,6 +135,18 @@ public enum AnalyticsEvent: String { ...@@ -135,6 +135,18 @@ public enum AnalyticsEvent: String {
case ANALYTICS_ONBOARDING_SKIP = "ONBOARDING_SKIP" case ANALYTICS_ONBOARDING_SKIP = "ONBOARDING_SKIP"
case ANALYTICS_ONBOARDING_SWIPE = "ONBOARDING_SWIPE" case ANALYTICS_ONBOARDING_SWIPE = "ONBOARDING_SWIPE"
///Premium
case ANALYTICS_SUBSCRIPTION_PREMIUM_CARD_CLICK = "PREMIUM_CARD_CLICK"
case ANALYTICS_SUBSCRIPTION_VIEW_PREMIUM_SCREEN = "VIEW_PREMIUM_SCREEN"
case ANALYTICS_SUBSCRIPTION_SUBSCRIBE_YEARLY_CLICK = "SUBSCRIBE_YEARLY_CLICK"
case ANALYTICS_SUBSCRIPTION_SUBSCRIBE_MONTHLY_CLICK = "SUBSCRIBE_MONTHLY_CLICK"
case ANALYTICS_SUBSCRIPTION_UPGRADE_CARD_CLICK = "UPGRADE_CARD_CLICK"
case ANALYTICS_SUBSCRIPTION_VIEW_PREMIUM_PRO_SCREEN = "VIEW_PREMIUM_PRO_SCREEN"
case ANALYTICS_SUBSCRIPTION_UPGRADE_YEARLY_CLICK = "UPGRADE_YEARLY_CLICK"
case ANALYTICS_SUBSCRIPTION_UPGRADE_MONTHLY_CLICK = "UPGRADE_MONTHLY_CLICK"
case ANALYTICS_SUBSCRIPTION_VIEW_PREMIUM_SUCCESS = "VIEW_PREMIUM_SUCCESS"
case ANALYTICS_SUBSCRIPTION_PREMIUM_HAMBURGER_CLICK = "PREMIUM_HAMBURGER_CLICK"
/// FTUE Funnel: User has saved his first city after installing the app. /// FTUE Funnel: User has saved his first city after installing the app.
case ANALYTICS_USER_QUALIFIED = "USER_QUALIFIED" case ANALYTICS_USER_QUALIFIED = "USER_QUALIFIED"
/// FTUE Funnel: User comes back to the app between the 3rd day after being qualified and 6th day. /// FTUE Funnel: User comes back to the app between the 3rd day after being qualified and 6th day.
......
...@@ -10,6 +10,7 @@ import Foundation ...@@ -10,6 +10,7 @@ import Foundation
public enum AnalyticsParameter: String { public enum AnalyticsParameter: String {
//TODO: rename to Swifty names. This is a legacy from the old app. //TODO: rename to Swifty names. This is a legacy from the old app.
case ANALYTICS_KEY_SOURCE = "source" case ANALYTICS_KEY_SOURCE = "source"
case ANALYTICS_KEY_GENERAL_TYPE = "type"
case ANALYTICS_KEY_CCPA_TODAY_DISMISS_ACTION = "ACTION" case ANALYTICS_KEY_CCPA_TODAY_DISMISS_ACTION = "ACTION"
case ANALYTICS_KEY_CCPA_TODAY_CCPA_EXPT_3_VERSION = "CCPA_EXPT_3_VERSION" case ANALYTICS_KEY_CCPA_TODAY_CCPA_EXPT_3_VERSION = "CCPA_EXPT_3_VERSION"
case ANALYTICS_KEY_LAST_SEEN_CITY_CITY_ID = "cityId" case ANALYTICS_KEY_LAST_SEEN_CITY_CITY_ID = "cityId"
...@@ -28,5 +29,4 @@ public enum AnalyticsParameter: String { ...@@ -28,5 +29,4 @@ public enum AnalyticsParameter: String {
case ANALYTICS_KEY_SHORTS_CARD_ID = "card_id" case ANALYTICS_KEY_SHORTS_CARD_ID = "card_id"
case ANALYTICS_KEY_SHORTS_TIME_SPENT = "time_spent" case ANALYTICS_KEY_SHORTS_TIME_SPENT = "time_spent"
case ANALYTICS_KEY_SHORTS_READ_MORE_VIEW = "view" case ANALYTICS_KEY_SHORTS_READ_MORE_VIEW = "view"
case ANALYTICS_KEY_SHORTS_NUDGE_VIEW_TYPE = "type"
} }
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
import Foundation import Foundation
import FirebaseCrashlytics import FirebaseCrashlytics
//TODO: logger needs to be moved out of Analytics into OneWeatherCore. We need to think of a way to get rid of the FirebaseCrashlytics dependency.
public final class Logger { public final class Logger {
public var minLogLevel: LogLevel public var minLogLevel: LogLevel
......
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD615F62265523A400B717DB"
BuildableName = "OneWeatherCore.framework"
BlueprintName = "OneWeatherCore"
ReferencedContainer = "container:OneWeatherCore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD615F6B265523A400B717DB"
BuildableName = "OneWeatherCoreTests.xctest"
BlueprintName = "OneWeatherCoreTests"
ReferencedContainer = "container:OneWeatherCore.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD615F62265523A400B717DB"
BuildableName = "OneWeatherCore.framework"
BlueprintName = "OneWeatherCore"
ReferencedContainer = "container:OneWeatherCore.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
...@@ -9,11 +9,9 @@ import Foundation ...@@ -9,11 +9,9 @@ import Foundation
public let kMoEngageAppId = "11PSBEC6K93IYU1AC8WIYADY" public let kMoEngageAppId = "11PSBEC6K93IYU1AC8WIYADY"
public let kEventInAppPurchasedCompleted = "EventInAppPurchasedCompleted"
public let a9AppKey = "2e440b094f7c44b4bae7044b764c61ac" public let a9AppKey = "2e440b094f7c44b4bae7044b764c61ac"
public let kAdMoPubInitializationAdUnitId = "05bff78d4a4245bd98ff6b595c134889" public let kAdMoPubInitializationAdUnitId = "05bff78d4a4245bd98ff6b595c134889"
public let kFlurryPartnerId = "2HJTQGPKT6VHXYRHFQTD" public let kFlurryPartnerId = "2HJTQGPKT6VHXYRHFQTD"
public let kOLAppMetricsKey: String = "OLAppMetricsKey"
public let WDT_APP_ID = "e3b73414" public let WDT_APP_ID = "e3b73414"
public let WDT_APP_KEY = "25e8d6b72de3bcd528f7769b073cc335" public let WDT_APP_KEY = "25e8d6b72de3bcd528f7769b073cc335"
......
...@@ -21,14 +21,6 @@ let BLANK = "--" ...@@ -21,14 +21,6 @@ let BLANK = "--"
let UP_ARROW = "\u{2191}" let UP_ARROW = "\u{2191}"
let DOWN_ARROW = "\u{2193}" let DOWN_ARROW = "\u{2193}"
// isPro
public func isAppPro() -> Bool {
if let metricsLog = UserDefaults.standard.dictionary(forKey: kOLAppMetricsKey) {
return metricsLog[kEventInAppPurchasedCompleted] != nil
}
return false
}
func windUnits() -> WindUnits { func windUnits() -> WindUnits {
return .mps return .mps
} }
......
//
// UIApplication+Settings.swift
// 1Weather
//
// Created by Dmitry Stepanets on 02.04.2021.
//
import UIKit
public extension UIApplication {
static func openSettings() {
guard
let bundleIdentifier = Bundle.main.bundleIdentifier,
let appSettingsURL = URL(string: UIApplication.openSettingsURLString + bundleIdentifier)
else {
assert(false, "Failed to create settings URL from: \(UIApplication.openSettingsURLString)")
return
}
UIApplication.shared.open(appSettingsURL)
}
}
...@@ -20,6 +20,33 @@ extension UserDefaults { ...@@ -20,6 +20,33 @@ extension UserDefaults {
return UserDefaults.standard return UserDefaults.standard
} }
private static func migrateInAppPurchaseMarker() {
// User Defaults - Old
let userDefaults = UserDefaults.standard
// App Groups Default - New
let groupDefaults = UserDefaults.appDefaults
let legacyAppMetricsKey = "OLAppMetricsKey"
let legacyInAppPurchaseEventKey = "EventInAppPurchasedCompleted"
let newInAppPurchaseMarkerKey = "com.inmobi.oneweather.WeatherKey" // we want something inconspicuous just so that it wasn't that obvious if anybody has a look at UserDefaults
var hasInAppPurchase = false
for userDefaults in [groupDefaults, userDefaults] {
if let appMetrics = userDefaults.dictionary(forKey: legacyAppMetricsKey) {
if appMetrics[legacyInAppPurchaseEventKey] != nil {
hasInAppPurchase = true
break
}
}
}
if hasInAppPurchase {
groupDefaults.set(true, forKey: newInAppPurchaseMarkerKey)
}
}
public static func migrateUserDefaultsToAppGroupsIfNeeded() { public static func migrateUserDefaultsToAppGroupsIfNeeded() {
// User Defaults - Old // User Defaults - Old
let userDefaults = UserDefaults.standard let userDefaults = UserDefaults.standard
...@@ -41,5 +68,8 @@ extension UserDefaults { ...@@ -41,5 +68,8 @@ extension UserDefaults {
} else { } else {
log.info("No need to migrate defaults") log.info("No need to migrate defaults")
} }
// Migrate in app purchase marker separately, because it was added later.
migrateInAppPurchaseMarker()
} }
} }
//
// AppFeature.swift
// OneWeatherCore
//
// Created by Demid Merzlyakov on 24.08.2021.
//
import Foundation
public enum AppFeature: String, Codable, CaseIterable {
case airQualityIndex
case attPrompt
case ads
case nwsAlertsViaMoEngage
case onboarding
case minutelyForecast
case extendedHourlyForecast
case extendedDailyForecast
case shorts
case shortsLastNudge
/// Any kind of subscription
case subscription
/// Discounted subscription for people who previously purchased an in-app to remove ads
case subscriptionForPro
}
//
// ClosureFeatureAvailabilityChecker.swift
// OneWeatherCore
//
// Created by Demid Merzlyakov on 08.09.2021.
//
import Foundation
public struct ClosureFeatureAvailabilityChecker: FeatureAvailabilityChecker {
private let checker: () -> Bool
public init(isAvailableChecker: @escaping () -> Bool) {
self.checker = isAvailableChecker
}
public var isAvailable: Bool {
checker()
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment