diff --git a/.github/workflows/ci-pull-request.yml b/.github/workflows/ci-pull-request.yml index a1b5635..9230a1a 100644 --- a/.github/workflows/ci-pull-request.yml +++ b/.github/workflows/ci-pull-request.yml @@ -4,8 +4,8 @@ on: branches: - 'main' workflow_dispatch: + jobs: - build-swiftpm: name: Build SwiftPM uses: oversizedev/GithubWorkflows/.github/workflows/build-swiftpm.yml@main @@ -19,10 +19,14 @@ jobs: build-example: name: Build Example needs: build-swiftpm - uses: oversizedev/GithubWorkflows/.github/workflows/build-ios-app.yml@main + uses: oversizedev/GithubWorkflows/.github/workflows/build-app.yml@main + strategy: + matrix: + destination: ['platform=iOS Simulator,name=iPhone 15 Pro,OS=17.2', 'platform=iOS Simulator,name=iPad Pro (12.9-inch) (6th generation),OS=17.2'] with: - folder: AppExample - app: Example + path: AppExample/Example + scheme: Example + destination: ${{ matrix.destination }} secrets: inherit # tests: diff --git a/.github/workflows/ci-push.yml b/.github/workflows/ci-push.yml index 58c3fd0..9803441 100644 --- a/.github/workflows/ci-push.yml +++ b/.github/workflows/ci-push.yml @@ -20,10 +20,14 @@ jobs: build-example: name: Build Example needs: build-swiftpm - uses: oversizedev/GithubWorkflows/.github/workflows/build-ios-app.yml@main + uses: oversizedev/GithubWorkflows/.github/workflows/build-app.yml@main + strategy: + matrix: + destination: ['platform=iOS Simulator,name=iPhone 15 Pro,OS=17.2', 'platform=iOS Simulator,name=iPad Pro (12.9-inch) (6th generation),OS=17.2'] with: - folder: AppExample - app: Example + path: AppExample/Example + scheme: Example + destination: ${{ matrix.destination }} secrets: inherit # tests: diff --git a/.gitignore b/.gitignore index b0a3eb8..5c5c727 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ Package.resolved .swiftpm xcuserdata/ -DerivedData/ \ No newline at end of file +/.swiftpm +DerivedData/ +/AppExample/Example.xcodeproj/project.xcworkspace/xcuserdata/admin.xcuserdatad/UserInterfaceState.xcuserstate diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/admin.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/admin.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..51ac161 Binary files /dev/null and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/admin.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/.swiftpm/xcode/xcuserdata/admin.xcuserdatad/xcschemes/xcschememanagement.plist b/.swiftpm/xcode/xcuserdata/admin.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..120e2c4 --- /dev/null +++ b/.swiftpm/xcode/xcuserdata/admin.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,77 @@ + + + + + SchemeUserState + + OversizeAdsKit.xcscheme_^#shared#^_ + + orderHint + 0 + + OversizeKit-Package.xcscheme_^#shared#^_ + + orderHint + 5 + + OversizeKitTests.xcscheme_^#shared#^_ + + orderHint + 13 + + + SuppressBuildableAutocreation + + OversizeAdsKit + + primary + + + OversizeCalendarKit + + primary + + + OversizeContactsKit + + primary + + + OversizeKit + + primary + + + OversizeKitTests + + primary + + + OversizeLocationKit + + primary + + + OversizeNoticeKit + + primary + + + OversizeNotificationKit + + primary + + + OversizeOnboardingKit + + primary + + + OversizePhotoKit + + primary + + + + + diff --git a/AppExample/Example.xcodeproj/project.pbxproj b/AppExample/Example.xcodeproj/project.pbxproj index b4928e8..a8d543f 100644 --- a/AppExample/Example.xcodeproj/project.pbxproj +++ b/AppExample/Example.xcodeproj/project.pbxproj @@ -22,7 +22,6 @@ 840CD68E2AC0E39D00C6AAD0 /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840CD68D2AC0E39D00C6AAD0 /* ExampleApp.swift */; }; 840CD6902AC0E3A600C6AAD0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 840CD68F2AC0E3A600C6AAD0 /* Assets.xcassets */; }; 840CD6932AC0E3A600C6AAD0 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 840CD6922AC0E3A600C6AAD0 /* Preview Assets.xcassets */; }; - 840CD69C2AC0E43000C6AAD0 /* OversizeAdsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 840CD69B2AC0E43000C6AAD0 /* OversizeAdsKit */; }; 840CD69E2AC0E43000C6AAD0 /* OversizeCalendarKit in Frameworks */ = {isa = PBXBuildFile; productRef = 840CD69D2AC0E43000C6AAD0 /* OversizeCalendarKit */; }; 840CD6A02AC0E43000C6AAD0 /* OversizeContactsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 840CD69F2AC0E43000C6AAD0 /* OversizeContactsKit */; }; 840CD6A22AC0E43000C6AAD0 /* OversizeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 840CD6A12AC0E43000C6AAD0 /* OversizeKit */; }; @@ -33,6 +32,7 @@ 840CD6AC2AC0E43000C6AAD0 /* OversizePhotoKit in Frameworks */ = {isa = PBXBuildFile; productRef = 840CD6AB2AC0E43000C6AAD0 /* OversizePhotoKit */; }; 840CD6AF2AC0E44E00C6AAD0 /* Factory in Frameworks */ = {isa = PBXBuildFile; productRef = 840CD6AE2AC0E44E00C6AAD0 /* Factory */; }; 840CD6B12AC0E6E200C6AAD0 /* Products.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 840CD6B02AC0E6E200C6AAD0 /* Products.storekit */; }; + 845A59332BA4FD2B00988D52 /* OversizeModels in Frameworks */ = {isa = PBXBuildFile; productRef = 845A59322BA4FD2B00988D52 /* OversizeModels */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -69,10 +69,10 @@ 840CD6AF2AC0E44E00C6AAD0 /* Factory in Frameworks */, 840CD69E2AC0E43000C6AAD0 /* OversizeCalendarKit in Frameworks */, 840CD6A82AC0E43000C6AAD0 /* OversizeNotificationKit in Frameworks */, - 840CD69C2AC0E43000C6AAD0 /* OversizeAdsKit in Frameworks */, 840CD6A22AC0E43000C6AAD0 /* OversizeKit in Frameworks */, 840CD6A42AC0E43000C6AAD0 /* OversizeLocationKit in Frameworks */, 840CD6A62AC0E43000C6AAD0 /* OversizeNoticeKit in Frameworks */, + 845A59332BA4FD2B00988D52 /* OversizeModels in Frameworks */, 840CD6A02AC0E43000C6AAD0 /* OversizeContactsKit in Frameworks */, 840CD6AA2AC0E43000C6AAD0 /* OversizeOnboardingKit in Frameworks */, ); @@ -206,7 +206,6 @@ ); name = Example; packageProductDependencies = ( - 840CD69B2AC0E43000C6AAD0 /* OversizeAdsKit */, 840CD69D2AC0E43000C6AAD0 /* OversizeCalendarKit */, 840CD69F2AC0E43000C6AAD0 /* OversizeContactsKit */, 840CD6A12AC0E43000C6AAD0 /* OversizeKit */, @@ -216,6 +215,7 @@ 840CD6A92AC0E43000C6AAD0 /* OversizeOnboardingKit */, 840CD6AB2AC0E43000C6AAD0 /* OversizePhotoKit */, 840CD6AE2AC0E44E00C6AAD0 /* Factory */, + 845A59322BA4FD2B00988D52 /* OversizeModels */, ); productName = Example; productReference = 840CD6632AC0E39D00C6AAD0 /* Example.app */; @@ -248,6 +248,7 @@ packageReferences = ( 840CD69A2AC0E43000C6AAD0 /* XCLocalSwiftPackageReference ".." */, 840CD6AD2AC0E44E00C6AAD0 /* XCRemoteSwiftPackageReference "Factory" */, + 845A59312BA4FD2B00988D52 /* XCRemoteSwiftPackageReference "OversizeModels" */, ); productRefGroup = 840CD6642AC0E39D00C6AAD0 /* Products */; projectDirPath = ""; @@ -515,13 +516,17 @@ minimumVersion = 2.2.0; }; }; + 845A59312BA4FD2B00988D52 /* XCRemoteSwiftPackageReference "OversizeModels" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/oversizedev/OversizeModels.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 840CD69B2AC0E43000C6AAD0 /* OversizeAdsKit */ = { - isa = XCSwiftPackageProductDependency; - productName = OversizeAdsKit; - }; 840CD69D2AC0E43000C6AAD0 /* OversizeCalendarKit */ = { isa = XCSwiftPackageProductDependency; productName = OversizeCalendarKit; @@ -559,6 +564,11 @@ package = 840CD6AD2AC0E44E00C6AAD0 /* XCRemoteSwiftPackageReference "Factory" */; productName = Factory; }; + 845A59322BA4FD2B00988D52 /* OversizeModels */ = { + isa = XCSwiftPackageProductDependency; + package = 845A59312BA4FD2B00988D52 /* XCRemoteSwiftPackageReference "OversizeModels" */; + productName = OversizeModels; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 840CD6592AC0E39D00C6AAD0 /* Project object */; diff --git a/AppExample/Example.xcodeproj/project.xcworkspace/xcuserdata/admin.xcuserdatad/UserInterfaceState.xcuserstate b/AppExample/Example.xcodeproj/project.xcworkspace/xcuserdata/admin.xcuserdatad/UserInterfaceState.xcuserstate index c5dd800..1cd4fb5 100644 Binary files a/AppExample/Example.xcodeproj/project.xcworkspace/xcuserdata/admin.xcuserdatad/UserInterfaceState.xcuserstate and b/AppExample/Example.xcodeproj/project.xcworkspace/xcuserdata/admin.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/AppExample/Example/Resources/Products.storekit b/AppExample/Example/Resources/Products.storekit index f9dfc04..98367b6 100644 --- a/AppExample/Example/Resources/Products.storekit +++ b/AppExample/Example/Resources/Products.storekit @@ -22,6 +22,8 @@ ], "settings" : { "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_storefront" : "USA", "_storeKitErrors" : [ { "current" : null, diff --git a/AppExample/Example/Router/Alerts.swift b/AppExample/Example/Router/Alerts.swift index b216f7c..153c230 100644 --- a/AppExample/Example/Router/Alerts.swift +++ b/AppExample/Example/Router/Alerts.swift @@ -6,6 +6,7 @@ import OversizeLocalizable import OversizeServices import SwiftUI +import OversizeModels enum RootAlert: Identifiable { case dismiss(_ action: () -> Void) diff --git a/AppExample/Example/Screens/AppSettings/AppSettingsView.swift b/AppExample/Example/Screens/AppSettings/AppSettingsView.swift index 625e930..9d6b36e 100644 --- a/AppExample/Example/Screens/AppSettings/AppSettingsView.swift +++ b/AppExample/Example/Screens/AppSettings/AppSettingsView.swift @@ -16,7 +16,6 @@ struct AppSettingsView: View { Image(systemName: "") } .rowArrow() - .multilineTextAlignment(.leading) } .buttonStyle(.row) diff --git a/Package.swift b/Package.swift index be0790f..c5b5396 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,7 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. +import Foundation import PackageDescription let productionDependencies: [PackageDescription.Package.Dependency] = [ @@ -14,6 +15,7 @@ let productionDependencies: [PackageDescription.Package.Dependency] = [ .package(url: "https://github.com/oversizedev/OversizeModels.git", .upToNextMajor(from: "0.1.0")), .package(url: "https://github.com/hmlongco/Factory.git", .upToNextMajor(from: "2.1.3")), .package(url: "https://github.com/lorenzofiamingo/swiftui-cached-async-image.git", .upToNextMajor(from: "2.1.1")), + .package(url: "https://github.com/GetStream/effects-library.git", .upToNextMajor(from: "1.0.0")), ] let developmentDependencies: [PackageDescription.Package.Dependency] = [ @@ -27,8 +29,11 @@ let developmentDependencies: [PackageDescription.Package.Dependency] = [ .package(name: "OversizeModels", path: "../OversizeModels"), .package(url: "https://github.com/lorenzofiamingo/swiftui-cached-async-image.git", .upToNextMajor(from: "2.1.1")), .package(url: "https://github.com/hmlongco/Factory.git", .upToNextMajor(from: "2.1.3")), + .package(url: "https://github.com/GetStream/effects-library.git", .upToNextMajor(from: "1.0.0")), ] +let isProductionDependencies = ProcessInfo.processInfo.environment["RELEASE_DEPENDENCIES"] == "TRUE" + let package = Package( name: "OversizeKit", platforms: [ @@ -39,7 +44,6 @@ let package = Package( ], products: [ .library(name: "OversizeKit", targets: ["OversizeKit"]), - .library(name: "OversizeAdsKit", targets: ["OversizeAdsKit"]), .library(name: "OversizeOnboardingKit", targets: ["OversizeOnboardingKit"]), .library(name: "OversizeNoticeKit", targets: ["OversizeNoticeKit"]), .library(name: "OversizeCalendarKit", targets: ["OversizeCalendarKit"]), @@ -65,19 +69,7 @@ let package = Package( .product(name: "OversizeNetwork", package: "OversizeNetwork"), .product(name: "Factory", package: "Factory"), .product(name: "CachedAsyncImage", package: "swiftui-cached-async-image"), - ] - ), - .target( - name: "OversizeAdsKit", - dependencies: [ - "OversizeKit", - .product(name: "Factory", package: "Factory"), - .product(name: "OversizeUI", package: "OversizeUI"), - .product(name: "OversizeServices", package: "OversizeServices"), - .product(name: "CachedAsyncImage", package: "swiftui-cached-async-image"), - .product(name: "OversizeCore", package: "OversizeCore"), - .product(name: "OversizeNetwork", package: "OversizeNetwork"), - .product(name: "OversizeModels", package: "OversizeModels"), + .product(name: "EffectsLibrary", package: "effects-library"), ] ), .target( diff --git a/Sources/OversizeCalendarKit/Pickers/AlertPicker.swift b/Sources/OversizeCalendarKit/Pickers/AlertPicker.swift index bdd4492..68fbfb8 100644 --- a/Sources/OversizeCalendarKit/Pickers/AlertPicker.swift +++ b/Sources/OversizeCalendarKit/Pickers/AlertPicker.swift @@ -33,7 +33,7 @@ public struct AlarmPicker: View { } } } - .surfaceContentRowInsets() + .surfaceContentRowMargins() } .backgroundSecondary() .leadingBar { diff --git a/Sources/OversizeAdsKit/AdView.swift b/Sources/OversizeKit/AdsKit/AdView.swift similarity index 64% rename from Sources/OversizeAdsKit/AdView.swift rename to Sources/OversizeKit/AdsKit/AdView.swift index 8417c99..aeb1c44 100644 --- a/Sources/OversizeAdsKit/AdView.swift +++ b/Sources/OversizeKit/AdsKit/AdView.swift @@ -5,7 +5,6 @@ import CachedAsyncImage import OversizeCore -import OversizeKit import OversizeModels import OversizeNetwork import OversizeServices @@ -50,33 +49,35 @@ public struct AdView: View { } } - func premiumBanner(appAd: Components.Schemas.AppShort) -> some View { + func premiumBanner(appAd: Components.Schemas.Ad) -> some View { HStack(spacing: .zero) { - CachedAsyncImage(url: URL(string: "\(Info.links?.company.cdnString ?? "")/assets/apps/\(appAd.address)/icon.png"), urlCache: .imageCache, content: { - $0 - .resizable() - .frame(width: 64, height: 64) - .mask(RoundedRectangle(cornerRadius: .large, - style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 16, - style: .continuous) - .stroke(lineWidth: 1) - .opacity(0.15) - ) - .onTapGesture { - isShowProduct.toggle() - } + if let iconUrl = appAd.iconURL, let url = URL(string: iconUrl) { + CachedAsyncImage(url: url, urlCache: .imageCache, content: { + $0 + .resizable() + .frame(width: 64, height: 64) + .mask(RoundedRectangle(cornerRadius: .large, + style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, + style: .continuous) + .stroke(lineWidth: 1) + .opacity(0.15) + ) + .onTapGesture { + isShowProduct.toggle() + } - }, placeholder: { - RoundedRectangle(cornerRadius: .large, style: .continuous) - .fillSurfaceSecondary() - .frame(width: 64, height: 64) - }) + }, placeholder: { + RoundedRectangle(cornerRadius: .large, style: .continuous) + .fillSurfaceSecondary() + .frame(width: 64, height: 64) + }) + } VStack(alignment: .leading, spacing: .xxxSmall) { HStack { - Text(appAd.name) + Text(appAd.title) .subheadline(.bold) .onSurfaceHighEmphasisForegroundColor() @@ -86,7 +87,7 @@ public struct AdView: View { } } - Text(appAd.title) + Text(appAd.description) .subheadline() .onSurfaceMediumEmphasisForegroundColor() } diff --git a/Sources/OversizeAdsKit/AdViewModel.swift b/Sources/OversizeKit/AdsKit/AdViewModel.swift similarity index 89% rename from Sources/OversizeAdsKit/AdViewModel.swift rename to Sources/OversizeKit/AdsKit/AdViewModel.swift index a17cbf0..98f2869 100644 --- a/Sources/OversizeAdsKit/AdViewModel.swift +++ b/Sources/OversizeKit/AdsKit/AdViewModel.swift @@ -18,7 +18,7 @@ public class AdViewModel: ObservableObject { public init() {} public func fetchAd() async { - let result = await networkService.fetchApps() + let result = await networkService.fetchAds() switch result { case let .success(ads): guard let ad = ads.filter({ $0.appStoreId != Info.app.appStoreID }).randomElement() else { @@ -36,7 +36,7 @@ extension AdViewModel { enum State { case initial case loading - case result(Components.Schemas.AppShort) + case result(Components.Schemas.Ad) case error(AppError) } } diff --git a/Sources/OversizeKit/LauncherKit/LauncherViewModel.swift b/Sources/OversizeKit/LauncherKit/LauncherViewModel.swift index d32203a..a8e4e0c 100644 --- a/Sources/OversizeKit/LauncherKit/LauncherViewModel.swift +++ b/Sources/OversizeKit/LauncherKit/LauncherViewModel.swift @@ -4,6 +4,7 @@ // import OversizeCore +import OversizeNetwork import OversizeServices import OversizeStoreService import OversizeUI @@ -20,15 +21,18 @@ public final class LauncherViewModel: ObservableObject { @Injected(\.settingsService) var settingsService @Injected(\.appStoreReviewService) var reviewService: AppStoreReviewServiceProtocol @Injected(\.storeKitService) private var storeKitService: StoreKitService + @Injected(\.networkService) var networkService @AppStorage("AppState.PremiumState") var isPremium: Bool = false @AppStorage("AppState.SubscriptionsState") var subscriptionsState: RenewalState = .expired - @AppStorage("AppState.LastClosedSpecialOfferSheet") var lastClosedSpecialOffer: StoreSpecialOfferEventType = .oldUser + @AppStorage("AppState.LastClosedSpecialOfferSheet") var lastClosedSpecialOffer: String = "" @Published public var pinCodeField: String = "" @Published public var authState: LockscreenViewState = .locked @Published var activeFullScreenSheet: FullScreenSheet? @Published var isShowSplashScreen: Bool = true + let expectedFormat = Date.ISO8601FormatStyle() + var isShowLockscreen: Bool { if FeatureFlags.secure.lookscreen ?? false { if settingsService.pinCodeEnabend || settingsService.biometricEnabled, authState != .unlocked { @@ -49,7 +53,7 @@ extension LauncherViewModel { case onboarding case payWall case rate - case specialOffer(event: StoreSpecialOfferEventType) + case specialOffer(event: Components.Schemas.SpecialOffer) public var id: Int { switch self { case .onboarding: return 0 @@ -130,13 +134,33 @@ public extension LauncherViewModel { } } - func checkSpecialOffer() { - if !isPremium { - for event in StoreSpecialOfferEventType.allCases where event.isNow { - if activeFullScreenSheet == nil, lastClosedSpecialOffer != event { - activeFullScreenSheet = .specialOffer(event: event) + func fetchAndSetSpecialOffer() async { + let result = await networkService.fetchSpecialOffers() + switch result { + case let .success(offers): + if let offer = offers.first(where: { checkDateInSelectedPeriod(startDate: $0.startDate, endDate: $0.endDate) }) { + if offer.id != lastClosedSpecialOffer { + activeFullScreenSheet = .specialOffer(event: offer) } } + case .failure: + break + } + } + + func checkDateInSelectedPeriod(startDate: Date, endDate: Date) -> Bool { + if startDate < endDate { + return (startDate ... endDate).contains(Date()) + } else { + return false + } + } + + func checkSpecialOffer() { + if !isPremium, activeFullScreenSheet == nil { + Task { + await fetchAndSetSpecialOffer() + } } } } diff --git a/Sources/OversizeKit/LockscreenKit/LockscreenView.swift b/Sources/OversizeKit/LockscreenKit/LockscreenView.swift index b5d1968..0833b73 100644 --- a/Sources/OversizeKit/LockscreenKit/LockscreenView.swift +++ b/Sources/OversizeKit/LockscreenKit/LockscreenView.swift @@ -255,6 +255,11 @@ public struct LockscreenView: View { .font(.system(size: 26)) .foregroundColor(Color.onBackgroundHighEmphasis) .frame(width: 24, height: 24, alignment: .center) + case .opticID: + Image(systemName: "opticid") + .font(.system(size: 26)) + .foregroundColor(Color.onBackgroundHighEmphasis) + .frame(width: 24, height: 24, alignment: .center) } } diff --git a/Sources/OversizeKit/SettingsKit/Views/About/About/AboutView.swift b/Sources/OversizeKit/SettingsKit/Views/About/About/AboutView.swift index d506acc..eaf732b 100644 --- a/Sources/OversizeKit/SettingsKit/Views/About/About/AboutView.swift +++ b/Sources/OversizeKit/SettingsKit/Views/About/About/AboutView.swift @@ -15,6 +15,7 @@ import SwiftUI // swiftlint:disable all #if os(iOS) import MessageUI + public struct AboutView: View { @Environment(\.verticalSizeClass) private var verticalSizeClass @Environment(\.isPortrait) var isPortrait @@ -208,7 +209,6 @@ import SwiftUI .foregroundColor(.onBackgroundHighEmphasis) .padding(.horizontal, isLargeScreen ? 72 : 52) .padding(.bottom, .large) - .multilineTextAlignment(.center) soclal @@ -219,7 +219,7 @@ import SwiftUI if let reviewUrl = Info.url.appStoreReview, let id = Info.app.appStoreID, !id.isEmpty, let appName = Info.app.name { Link(destination: reviewUrl) { Row("Rate \(appName) on App Store") { - rateSettingsIcon + rateSettingsIcon.icon() } } .buttonStyle(.row) @@ -239,7 +239,7 @@ import SwiftUI Row(L10n.About.suggestIdea) { isShowMail.toggle() } leading: { - ideaSettingsIcon + ideaSettingsIcon.icon() } .buttonStyle(.row) @@ -253,7 +253,7 @@ import SwiftUI Row(L10n.Settings.shareApplication) { isSharePresented.toggle() } leading: { - shareSettingsIcon + shareSettingsIcon.icon() } .sheet(isPresented: $isSharePresented) { ActivityViewController(activityItems: [shareUrl]) @@ -491,7 +491,7 @@ import SwiftUI let appName = Info.app.name, let appBuild = Info.app.build { - Text("© 2022 \(developerName). \(appName) \(appVersion) (\(appBuild))") + Text("© 2023 \(developerName). \(appName) \(appVersion) (\(appBuild))") .footnote() .foregroundColor(.onBackgroundDisabled) } else { diff --git a/Sources/OversizeKit/SettingsKit/Views/About/About/AboutViewModel.swift b/Sources/OversizeKit/SettingsKit/Views/About/About/AboutViewModel.swift index 00b9693..0379448 100644 --- a/Sources/OversizeKit/SettingsKit/Views/About/About/AboutViewModel.swift +++ b/Sources/OversizeKit/SettingsKit/Views/About/About/AboutViewModel.swift @@ -22,7 +22,6 @@ public class AboutViewModel: ObservableObject { async let resultInfo = networkService.fetchInfo() if case let .success(apps) = await resultApps, case let .success(info) = await resultInfo { state = .result(apps.filter { $0.appStoreId != Info.app.appStoreID }, info) - } else { state = .error(.network(type: .noResponse)) } diff --git a/Sources/OversizeKit/SettingsKit/Views/About/FeedbackView.swift b/Sources/OversizeKit/SettingsKit/Views/About/FeedbackView.swift index cf97d05..976faa1 100644 --- a/Sources/OversizeKit/SettingsKit/Views/About/FeedbackView.swift +++ b/Sources/OversizeKit/SettingsKit/Views/About/FeedbackView.swift @@ -43,7 +43,7 @@ public struct FeedbackView: View { if let reviewUrl = Info.url.appStoreReview, let id = Info.app.appStoreID, !id.isEmpty { Link(destination: reviewUrl) { Row(L10n.Settings.feedbakAppStore) { - heartIcon + heartIcon.icon() } } .buttonStyle(.row) @@ -65,7 +65,7 @@ public struct FeedbackView: View { Row(L10n.Settings.feedbakAuthor) { isShowMail.toggle() } leading: { - mailIcon + mailIcon.icon() } .buttonStyle(.row) @@ -77,7 +77,7 @@ public struct FeedbackView: View { if let sendMailUrl = Info.url.developerSendMail { Link(destination: sendMailUrl) { Row(L10n.Settings.feedbakAuthor) { - mailIcon + mailIcon.icon() } } .buttonStyle(.row) @@ -89,7 +89,7 @@ public struct FeedbackView: View { if let telegramChatUrl = Info.url.appTelegramChat, let id = Info.app.telegramChatID, !id.isEmpty { Link(destination: telegramChatUrl) { Row(L10n.Settings.telegramChat) { - chatIcon + chatIcon.icon() } } .buttonStyle(.row) diff --git a/Sources/OversizeKit/SettingsKit/Views/About/OurResorsesView.swift b/Sources/OversizeKit/SettingsKit/Views/About/OurResorsesView.swift index cac6b70..745cb19 100644 --- a/Sources/OversizeKit/SettingsKit/Views/About/OurResorsesView.swift +++ b/Sources/OversizeKit/SettingsKit/Views/About/OurResorsesView.swift @@ -35,7 +35,7 @@ public struct OurResorsesView: View { if let gitHubUrl = URL(string: "https://github.com/oversizedev") { Link(destination: gitHubUrl) { Row("GitHub Open Source") { - githubIcon + githubIcon.icon() } } .buttonStyle(.row) @@ -44,7 +44,7 @@ public struct OurResorsesView: View { if let figmaUrl = URL(string: "https://www.figma.com/@oversizedesign") { Link(destination: figmaUrl) { Row("Figma Community") { - figmaIcon + figmaIcon.icon() } } .buttonStyle(.row) diff --git a/Sources/OversizeKit/SettingsKit/Views/About/SupportView.swift b/Sources/OversizeKit/SettingsKit/Views/About/SupportView.swift index af969c6..cc4c5c4 100644 --- a/Sources/OversizeKit/SettingsKit/Views/About/SupportView.swift +++ b/Sources/OversizeKit/SettingsKit/Views/About/SupportView.swift @@ -56,7 +56,7 @@ public struct SupportView: View { Row("Contact Us") { isShowMail.toggle() } leading: { - mailIcon + mailIcon.icon() } .buttonStyle(.row) @@ -68,7 +68,7 @@ public struct SupportView: View { if let sendMailUrl = Info.url.developerSendMail { Link(destination: sendMailUrl) { Row("Contact Us") { - mailIcon + mailIcon.icon() } } .buttonStyle(.row) @@ -80,7 +80,7 @@ public struct SupportView: View { if let telegramChatUrl = Info.url.appTelegramChat, let id = Info.app.telegramChatID, !id.isEmpty { Link(destination: telegramChatUrl) { Row(L10n.Settings.telegramChat) { - chatIcon + chatIcon.icon() } } .buttonStyle(.row) diff --git a/Sources/OversizeKit/SettingsKit/Views/Apperance/AppearanceSettingView.swift b/Sources/OversizeKit/SettingsKit/Views/Apperance/AppearanceSettingView.swift index 701b88d..2423055 100644 --- a/Sources/OversizeKit/SettingsKit/Views/Apperance/AppearanceSettingView.swift +++ b/Sources/OversizeKit/SettingsKit/Views/Apperance/AppearanceSettingView.swift @@ -109,7 +109,6 @@ import SwiftUI // swiftlint:disable multiple_closures_with_trailing_closure superfluous_disable_command .navigationTitle("Appearance") - .preferredColorScheme(theme.appearance.colorScheme) } @@ -225,7 +224,7 @@ import SwiftUI Row("Fonts") { pageDestenation = .font } leading: { - textIcon + textIcon.icon() } .rowArrow() .premium() @@ -235,7 +234,7 @@ import SwiftUI Row("Borders") { pageDestenation = .border } leading: { - borderIcon + borderIcon.icon() } .premium() } @@ -256,7 +255,7 @@ import SwiftUI Row("Radius") { pageDestenation = .radius } leading: { - radiusIcon + radiusIcon.icon() } .rowArrow() .premium() diff --git a/Sources/OversizeKit/SettingsKit/Views/Apperance/BorderSettongView.swift b/Sources/OversizeKit/SettingsKit/Views/Apperance/BorderSettongView.swift index 9c7db57..5092940 100644 --- a/Sources/OversizeKit/SettingsKit/Views/Apperance/BorderSettongView.swift +++ b/Sources/OversizeKit/SettingsKit/Views/Apperance/BorderSettongView.swift @@ -14,7 +14,7 @@ public struct BorderSettingView: View { public var body: some View { PageView("Borders in app") { settings - .surfaceContentRowInsets() + .surfaceContentRowMargins() } .leadingBar { // if !isPortrait, verticalSizeClass == .regular { @@ -63,7 +63,7 @@ public struct BorderSettingView: View { } } .surfaceStyle(.secondary) - .surfaceContentInsets(.small) + .surfaceContentMargins(.small) .padding(.horizontal, Space.medium) .padding(.bottom, Space.xxSmall) diff --git a/Sources/OversizeKit/SettingsKit/Views/Apperance/FontSettingView.swift b/Sources/OversizeKit/SettingsKit/Views/Apperance/FontSettingView.swift index b09fbf7..ed4fba7 100644 --- a/Sources/OversizeKit/SettingsKit/Views/Apperance/FontSettingView.swift +++ b/Sources/OversizeKit/SettingsKit/Views/Apperance/FontSettingView.swift @@ -32,7 +32,6 @@ public struct FontSettingView: View { } .padding(.horizontal) .padding(.bottom) - .navigationBar("Fonts", style: .fixed($offset)) { BarButton(.back) } trailingBar: {} bottomBar: {} diff --git a/Sources/OversizeKit/SettingsKit/Views/Apperance/RadiusSettingView.swift b/Sources/OversizeKit/SettingsKit/Views/Apperance/RadiusSettingView.swift index 2b47884..c42d83a 100644 --- a/Sources/OversizeKit/SettingsKit/Views/Apperance/RadiusSettingView.swift +++ b/Sources/OversizeKit/SettingsKit/Views/Apperance/RadiusSettingView.swift @@ -14,7 +14,7 @@ struct RadiusSettingView: View { public var body: some View { PageView("Radius") { settings - .surfaceContentRowInsets() + .surfaceContentRowMargins() } .leadingBar { BarButton(.back) diff --git a/Sources/OversizeKit/SettingsKit/Views/Notifications/NotificationsSettingsView.swift b/Sources/OversizeKit/SettingsKit/Views/Notifications/NotificationsSettingsView.swift index c72f575..6129783 100644 --- a/Sources/OversizeKit/SettingsKit/Views/Notifications/NotificationsSettingsView.swift +++ b/Sources/OversizeKit/SettingsKit/Views/Notifications/NotificationsSettingsView.swift @@ -20,7 +20,7 @@ import SwiftUI public var body: some View { PageView(L10n.Settings.notifications) { soundsAndVibrations - .surfaceContentRowInsets() + .surfaceContentRowMargins() } .leadingBar { BarButton(.back) diff --git a/Sources/OversizeKit/SettingsKit/Views/Security/SecuritySettingsView.swift b/Sources/OversizeKit/SettingsKit/Views/Security/SecuritySettingsView.swift index d70f40e..bc89bab 100644 --- a/Sources/OversizeKit/SettingsKit/Views/Security/SecuritySettingsView.swift +++ b/Sources/OversizeKit/SettingsKit/Views/Security/SecuritySettingsView.swift @@ -60,7 +60,6 @@ import SwiftUI Row(biometricService.biometricType.rawValue) { Image(systemName: biometricImageName) .foregroundColor(Color.onBackgroundHighEmphasis) - .font(.system(size: 20, weight: .semibold)) .frame(width: 24, height: 24, alignment: .center) } @@ -80,7 +79,7 @@ import SwiftUI }) ) { Row(L10n.Security.pinCode) { - Image.Security.lock + Image.Security.lock.icon() } }.sheet(item: $isSetPINCodeSheet) { sheet in SetPINCodeView(action: sheet) @@ -158,12 +157,12 @@ import SwiftUI switch biometricService.biometricType { case .none: return "" - case .touchID: return "touchid" - case .faceID: return "faceid" + case .opticID: + return "opticid" } } } diff --git a/Sources/OversizeKit/SettingsKit/Views/SettingsView.swift b/Sources/OversizeKit/SettingsKit/Views/SettingsView.swift index dbd5f10..0a8d144 100644 --- a/Sources/OversizeKit/SettingsKit/Views/SettingsView.swift +++ b/Sources/OversizeKit/SettingsKit/Views/SettingsView.swift @@ -271,7 +271,6 @@ import SwiftUI helpIcon.icon() } .rowArrow(isShowArrow) - .buttonStyle(.row) .sheet(isPresented: $isShowSupport) { SupportView() @@ -287,7 +286,6 @@ import SwiftUI chatIcon.icon() } .rowArrow(isShowArrow) - .buttonStyle(.row) .sheet(isPresented: $isShowFeedback) { FeedbackView() diff --git a/Sources/OversizeKit/SettingsKit/Views/SoundAndVibration/SoundsAndVibrationsSettingsView.swift b/Sources/OversizeKit/SettingsKit/Views/SoundAndVibration/SoundsAndVibrationsSettingsView.swift index 031d1fb..ed0f81c 100644 --- a/Sources/OversizeKit/SettingsKit/Views/SoundAndVibration/SoundsAndVibrationsSettingsView.swift +++ b/Sources/OversizeKit/SettingsKit/Views/SoundAndVibration/SoundsAndVibrationsSettingsView.swift @@ -70,7 +70,7 @@ import SwiftUI if FeatureFlags.app.vibration.valueOrFalse { Switch(isOn: $settingsService.vibrationEnabled) { Row(L10n.Settings.vibration) { - vibrationIcon + vibrationIcon.icon() } } } diff --git a/Sources/OversizeKit/SettingsKit/Views/iCloud/iCloudSettingsView.swift b/Sources/OversizeKit/SettingsKit/Views/iCloud/iCloudSettingsView.swift index d83cc16..59eab6b 100644 --- a/Sources/OversizeKit/SettingsKit/Views/iCloud/iCloudSettingsView.swift +++ b/Sources/OversizeKit/SettingsKit/Views/iCloud/iCloudSettingsView.swift @@ -20,7 +20,7 @@ import SwiftUI public var body: some View { PageView(L10n.Title.synchronization) { iOSSettings - .surfaceContentRowInsets() + .surfaceContentRowMargins() } .leadingBar { BarButton(.back) @@ -44,7 +44,7 @@ import SwiftUI if FeatureFlags.app.сloudKit.valueOrFalse { Switch(isOn: $settingsService.cloudKitEnabled) { Row(L10n.Settings.iCloudSync) { - Image.Weather.Cloud.square + Image.Weather.Cloud.square.icon() } .premium() } @@ -57,6 +57,7 @@ import SwiftUI subtitle: settingsService.cloudKitCVVEnabled ? L10n.Security.iCloudSyncCVVDescriptionCloudKit : L10n.Security.iCloudSyncCVVDescriptionLocal) { Image.Security.cloudLock + .icon() .frame(width: 24, height: 24) } .premium() @@ -67,7 +68,7 @@ import SwiftUI if FeatureFlags.app.healthKit.valueOrFalse { Switch(isOn: $settingsService.healthKitEnabled) { Row("HealthKit synchronization", subtitle: "After switching on, data from the Health app will be downloaded") { - Image.Romantic.heart + Image.Romantic.heart.icon() } } } diff --git a/Sources/OversizeKit/StoreKit/StoreScreen/StoreSpecialOfferView.swift b/Sources/OversizeKit/StoreKit/StoreScreen/StoreSpecialOfferView.swift index a1d0b70..292b1bc 100644 --- a/Sources/OversizeKit/StoreKit/StoreScreen/StoreSpecialOfferView.swift +++ b/Sources/OversizeKit/StoreKit/StoreScreen/StoreSpecialOfferView.swift @@ -3,8 +3,12 @@ // StoreSpecialOfferView.swift // +import CachedAsyncImage +import EffectsLibrary import OversizeComponents +import OversizeCore import OversizeLocalizable +import OversizeNetwork import OversizeResources import OversizeServices import OversizeStoreService @@ -16,22 +20,47 @@ public struct StoreSpecialOfferView: View { @Environment(\.dismiss) private var dismiss @Environment(\.isPremium) private var isPremium @StateObject private var viewModel: StoreViewModel - @AppStorage("AppState.LastClosedSpecialOfferSheet") private var lastClosedSpecialOffer: StoreSpecialOfferEventType = .oldUser + @AppStorage("AppState.LastClosedSpecialOfferSheet") private var lastClosedSpecialOffer: String = "0" @State private var isShowAllPlans = false @State private var offset: CGFloat = 0 - private let event: StoreSpecialOfferEventType + private let event: Components.Schemas.SpecialOffer @State var trialDaysPeriodText: String = "" + @State var salePercent: Decimal = 0 - public init(event: StoreSpecialOfferEventType = .newUser) { + public init(event: Components.Schemas.SpecialOffer) { self.event = event _viewModel = StateObject(wrappedValue: StoreViewModel(specialOfferMode: true)) } public var body: some View { #if os(iOS) - PageView { offset = $0 } content: { + Group { + if #available(iOS 16.0, *) { + newPage + } else { + oldPage + } + } + + .onChange(of: isPremium) { status in + if status { + dismiss() + } + } + .task { + await viewModel.fetchData() + } + #else + EmptyView() + #endif + } + + @available(iOS 16.0, *) + var newPage: some View { + NavigationStack { + Page(badgeText, onScroll: handleOffset) { Group { switch viewModel.state { case .initial: @@ -48,47 +77,109 @@ public struct StoreSpecialOfferView: View { ProgressView() case let .result(data): content(data: data) + .background { + effectsView + } case let .error(error): ErrorView(error) } } - .paddingContent(.horizontal) } .backgroundLinerGradient(LinearGradient(colors: [.backgroundPrimary, .backgroundSecondary], startPoint: .top, endPoint: .center)) - .titleLabel { - PremiumLabel(image: Resource.Store.zap, text: Info.store.subscriptionsName, size: .medium) - } - .trailingBar { - BarButton(.closeAction { - lastClosedSpecialOffer = event - dismiss() - }) - } - .bottomToolbar(style: .none) { - VStack(spacing: .zero) { - StorePaymentButtonBar() + .bottomToolbar(style: .gradient) { + VStack(spacing: .small) { + productsLust + .padding(.horizontal, .medium) + + StorePaymentButtonBar(showDescription: false) .environmentObject(viewModel) - .padding(.horizontal, 8) + .padding(.horizontal, .small) } } - .onChange(of: isPremium) { status in - if status { - dismiss() + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + lastClosedSpecialOffer = event.id + dismiss() + } label: { + Image.Base.close.icon() + } } } - .task { - await viewModel.fetchData() - } - #else + } + } + + @ViewBuilder + var effectsView: some View { + switch event.effect { + case .snow: + SnowView(config: .init( + intensity: .low, + lifetime: .long, + initialVelocity: .medium, + fadeOut: .slow, + spreadRadius: .high + )) + .offset(y: -150) + default: EmptyView() - #endif + } + } + + var oldPage: some View { + PageView { offset = $0 } content: { + Group { + switch viewModel.state { + case .initial: + VStack { + Spacer() + HStack { + Spacer() + ProgressView() + Spacer() + } + Spacer() + } + case .loading: + ProgressView() + case let .result(data): + content(data: data) + case let .error(error): + ErrorView(error) + } + } + .paddingContent(.horizontal) + } + .backgroundLinerGradient(LinearGradient(colors: [.backgroundPrimary, .backgroundSecondary], startPoint: .top, endPoint: .center)) + .titleLabel { + PremiumLabel(image: Resource.Store.zap, text: Info.store.subscriptionsName, size: .medium) + } + .trailingBar { + BarButton(.closeAction { + lastClosedSpecialOffer = event.id + dismiss() + }) + } + .bottomToolbar(style: .none) { + VStack(spacing: .zero) { + productsLust + StorePaymentButtonBar() + .environmentObject(viewModel) + .padding(.horizontal, 8) + } + } + } + + func handleOffset(_ scrollOffset: CGPoint, visibleHeaderRatio _: CGFloat) { + offset = -scrollOffset.y + // visibleRatio = visibleHeaderRatio } var imageSize: CGFloat { if screenSize.height > 830 { - return 144 - } else if screenSize.height > 800 { - return 98 + return 200 + } else if screenSize.height > 700 { + return 160 } else { return 64 } @@ -97,31 +188,29 @@ public struct StoreSpecialOfferView: View { @ViewBuilder private func content(data: StoreKitProducts) -> some View { ScrollViewReader { value in - VStack(spacing: .medium) { VStack(spacing: .zero) { - if screenSize.height > 810 { + PremiumLabel(image: Resource.Store.zap, text: Info.store.subscriptionsName, size: .medium) + .offset(y: -32) + + if screenSize.height > 850 { Spacer() } - AsyncIllustrationView(event.specialOfferImageURL) - .frame(width: imageSize, height: imageSize) - .padding(.bottom, screenSize.height > 810 ? 38 : 8) - - VStack(spacing: .xSmall) { - Text(event.specialOfferSubtitle.uppercased()) - .footnote(.semibold) - .onBackgroundMediumEmphasisForegroundColor() - - Text(event.isNeedTrialDescription ? event.specialOfferTitle + " " + trialDaysPeriodText : event.specialOfferTitle) - .title(.bold) - .foregroundColor(.onSurfaceHighEmphasis) - - Text(event.specialOfferDescription) - .foregroundColor(.onSurfaceMediumEmphasis) - .headline(.semibold) + if let imageURLString = event.imageURL, let imageURL = URL(string: imageURLString) { + CachedAsyncImage(url: imageURL, urlCache: .imageCache) { image in + image + .resizable() + .frame(width: imageSize, height: imageSize) + } placeholder: { + Circle() + .fill(Color.surfaceTertiary) + .frame(width: imageSize, height: imageSize) + } + .padding(.bottom, .small) + .zIndex(999_999_999) } - .multilineTextAlignment(.center) + titleTexts Button { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { @@ -137,7 +226,6 @@ public struct StoreSpecialOfferView: View { .padding(.bottom, screenSize.height > 810 ? .small : .zero) Spacer() - productsLust(data: data) } .frame(height: screenSize.safeAreaHeight - 235) .overlay { @@ -145,7 +233,7 @@ public struct StoreSpecialOfferView: View { .stroke(style: StrokeStyle(lineWidth: 5, lineCap: .round)) .foregroundColor(.onSurfaceHighEmphasis.opacity(0.3)) .frame(width: 30) - .offset(y: screenSize.safeAreaHeight - 370) + .offset(y: screenSize.safeAreaHeight - 280) .opacity(1 - (offset * 0.01)) } @@ -154,6 +242,7 @@ public struct StoreSpecialOfferView: View { .title() .onBackgroundHighEmphasisForegroundColor() .multilineTextAlignment(.center) + .fixedSize() .padding(.top, .large) StoreFeaturesLargeView() @@ -164,46 +253,137 @@ public struct StoreSpecialOfferView: View { .id(10) SubscriptionPrivacyView(products: data) + .padding(.horizontal, .medium) + .padding(.bottom, .large) } .padding(.bottom, 180) - - .onAppear { - Task { - // When this view appears, get the latest subscription status. - await viewModel.updateSubscriptionStatus(products: data) - } + .task { + await viewModel.updateSubscriptionStatus(products: data) } .onChange(of: data.purchasedAutoRenewable) { _ in Task { - // When `purchasedSubscriptions` changes, get the latest subscription status. await viewModel.updateSubscriptionStatus(products: data) } } } } + var titleTexts: some View { + VStack(spacing: .zero) { + Text(badgeText.uppercased()) + .footnote(.semibold) + .onBackgroundMediumEmphasisForegroundColor() + .padding(.bottom, .xxxSmall) + + Text(headline) + .title(.bold) + .foregroundColor(.onSurfaceHighEmphasis) + .frame(maxWidth: .infinity, alignment: .center) + + Text(event.title) + .largeTitle(.heavy) + .foregroundColor(titleColor) + + Text(description) + .foregroundColor(.onSurfaceMediumEmphasis) + .headline(.regular) + .padding(.top, .xSmall) + } + .multilineTextAlignment(.center) + .background { + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill(LinearGradient( + stops: [ + .init(color: Color.surfaceSecondary, location: 0), + .init(color: Color.surfaceSecondary.opacity(0), location: 0.7), + ], + startPoint: .top, + endPoint: .bottom + )) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .strokeBorder( + LinearGradient( + stops: [ + .init(color: Color.surfaceTertiary, location: 0), + .init(color: Color.surfaceSecondary.opacity(0), location: 0.7), + ], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 2 + ) + ) + .padding(.top, -54) + .padding(.bottom, -100) + } + .padding(.horizontal, .small) + } + + var badgeText: String { + if let badge = event.badge { + return textPrepere(badge) + } else { + return "" + } + } + + var headline: String { + if let headline = event.headline { + return textPrepere(headline) + } else { + return "" + } + } + + var titleColor: Color { + if let accentColor = event.accentColor { + return Color(hex: accentColor) + } else { + return Color.onBackgroundHighEmphasis + } + } + + var description: String { + if let description = event.description { + return textPrepere(description) + } else { + return "" + } + } + + func textPrepere(_ text: String) -> String { + text + .replacingOccurrences(of: "", with: salePercent.toString) + .replacingOccurrences(of: "", with: trialDaysPeriodText) + .replacingOccurrences(of: "", with: Info.store.subscriptionsName) + } + @ViewBuilder - func productsLust(data: StoreKitProducts) -> some View { - VStack(spacing: .small) { - ForEach(viewModel.availableSubscriptions) { product in - if product.isOffer { - StoreProductView(product: product, products: data, isSelected: .constant(false)) { - Task { - await viewModel.buy(product: product) + var productsLust: some View { + if case let .result(data) = viewModel.state { + VStack(spacing: .small) { + ForEach(viewModel.availableSubscriptions) { product in + if product.isOffer { + StoreProductView(product: product, products: data, isSelected: .constant(false)) { + Task { + await viewModel.buy(product: product) + } } - } - .onAppear { - if product.type == .autoRenewable, let offer = product.subscription?.introductoryOffer { - trialDaysPeriodText = viewModel.storeKitService.daysLabel(offer.period.value, unit: offer.period.unit) + .onAppear { + if product.type == .autoRenewable, let offer = product.subscription?.introductoryOffer { + trialDaysPeriodText = viewModel.storeKitService.daysLabel(offer.period.value, unit: offer.period.unit) + salePercent = viewModel.storeKitService.salePercent(product: product, products: data) + } } } } - } - ForEach(data.nonConsumable) { product in - if product.isOffer { - StoreProductView(product: product, products: data, isSelected: .constant(false)) { - Task { - await viewModel.buy(product: product) + ForEach(data.nonConsumable) { product in + if product.isOffer { + StoreProductView(product: product, products: data, isSelected: .constant(false)) { + Task { + await viewModel.buy(product: product) + } } } } @@ -212,8 +392,8 @@ public struct StoreSpecialOfferView: View { } } -struct StoreSpecialOfferView_Previews: PreviewProvider { - static var previews: some View { - StoreSpecialOfferView() - } -} +// struct StoreSpecialOfferView_Previews: PreviewProvider { +// static var previews: some View { +// StoreSpecialOfferView() +// } +// } diff --git a/Sources/OversizeKit/StoreKit/StoreScreen/ViewModel/StoreViewModel.swift b/Sources/OversizeKit/StoreKit/StoreScreen/ViewModel/StoreViewModel.swift index 00dddcb..708b461 100644 --- a/Sources/OversizeKit/StoreKit/StoreScreen/ViewModel/StoreViewModel.swift +++ b/Sources/OversizeKit/StoreKit/StoreScreen/ViewModel/StoreViewModel.swift @@ -14,7 +14,7 @@ import StoreKit import SwiftUI @MainActor -class StoreViewModel: ObservableObject { +public class StoreViewModel: ObservableObject { enum State { case initial case loading diff --git a/Sources/OversizeKit/StoreKit/ViewModifier/PremiumBlockOverlay.swift b/Sources/OversizeKit/StoreKit/ViewModifier/PremiumBlockOverlay.swift index fec8d7a..63e1aeb 100644 --- a/Sources/OversizeKit/StoreKit/ViewModifier/PremiumBlockOverlay.swift +++ b/Sources/OversizeKit/StoreKit/ViewModifier/PremiumBlockOverlay.swift @@ -10,23 +10,24 @@ import SwiftUI public struct PremiumBlockOverlay: ViewModifier { @Environment(\.colorScheme) var colorScheme @State var isShowPremium = false - @Environment(\.isPremium) var premiumStatus + @Environment(\.isPremium) var isPremium + @Binding var isShow: Bool let title: String let subtitle: String? private let closeAction: (() -> Void)? + - public init(title: String, subtitle: String?, closeAction: (() -> Void)? = nil) { + public init(isShow: Binding = .constant(true), title: String, subtitle: String?, closeAction: (() -> Void)? = nil) { + self._isShow = isShow self.title = title self.subtitle = subtitle self.closeAction = closeAction } public func body(content: Content) -> some View { - if premiumStatus { - content - } else { + if !isPremium && isShow { ZStack { content @@ -42,14 +43,14 @@ public struct PremiumBlockOverlay: ViewModifier { PremiumLabel(size: .medium) .padding(.bottom, .medium) - VStack(spacing: .medium) { + VStack(spacing: .small) { Text(title) .title() .foregroundColor(.onSurfaceHighEmphasis) if let subtitle { Text(subtitle) - .headline() + .headline(.medium) .foregroundColor(.onSurfaceMediumEmphasis) } } @@ -81,12 +82,24 @@ public struct PremiumBlockOverlay: ViewModifier { StoreView() .colorScheme(colorScheme) } + } else { + content } } } public extension View { + + func premiumContent(_ title: String, subtitle: String?, closeAction: (() -> Void)? = nil) -> some View { + modifier(PremiumBlockOverlay(title: title, subtitle: subtitle, closeAction: closeAction)) + } + + @available(*, deprecated, renamed: "premiumContent", message: "Renamed") func premiumContent(title: String, subtitle: String?, closeAction: (() -> Void)? = nil) -> some View { modifier(PremiumBlockOverlay(title: title, subtitle: subtitle, closeAction: closeAction)) } + + func premiumContent(isShow: Binding = .constant(true), title: String, subtitle: String?, closeAction: (() -> Void)? = nil) -> some View { + modifier(PremiumBlockOverlay(isShow: isShow, title: title, subtitle: subtitle, closeAction: closeAction)) + } } diff --git a/Sources/OversizeKit/StoreKit/Views/BuyButtonStyle.swift b/Sources/OversizeKit/StoreKit/Views/BuyButtonStyle.swift index 531bee8..3922ccd 100644 --- a/Sources/OversizeKit/StoreKit/Views/BuyButtonStyle.swift +++ b/Sources/OversizeKit/StoreKit/Views/BuyButtonStyle.swift @@ -88,7 +88,7 @@ public struct PaymentButtonStyle: ButtonStyle { return .small case .regular: return .small - case .large: + case .large, .extraLarge: return .medium @unknown default: return .zero @@ -103,7 +103,7 @@ public struct PaymentButtonStyle: ButtonStyle { return .xxSmall case .regular: return .small - case .large: + case .large, .extraLarge: return .medium @unknown default: return .zero diff --git a/Sources/OversizeKit/StoreKit/Views/StorePaymentButtonBar.swift b/Sources/OversizeKit/StoreKit/Views/StorePaymentButtonBar.swift index 4436eae..0399d3b 100644 --- a/Sources/OversizeKit/StoreKit/Views/StorePaymentButtonBar.swift +++ b/Sources/OversizeKit/StoreKit/Views/StorePaymentButtonBar.swift @@ -10,18 +10,22 @@ struct StorePaymentButtonBar: View { let action: (() -> Void)? let trialNotification: Bool + let showDescription: Bool - init(trialNotification: Bool = false, action: (() -> Void)? = nil) { + init(trialNotification: Bool = false, showDescription: Bool = true, action: (() -> Void)? = nil) { self.trialNotification = trialNotification self.action = action + self.showDescription = showDescription } var body: some View { VStack(spacing: .zero) { - Text(viewModel.selectedProductButtonDescription) - .subheadline(.semibold) - .foregroundColor(.onSurfaceMediumEmphasis) - .padding(.vertical, 20) + if showDescription { + Text(viewModel.selectedProductButtonDescription) + .subheadline(.semibold) + .foregroundColor(.onSurfaceMediumEmphasis) + .padding(.vertical, 20) + } Button { if let selectedProduct = viewModel.selectedProduct { @@ -50,10 +54,12 @@ struct StorePaymentButtonBar: View { } .padding(.bottom, .xxSmall) .background { - backgroundView + if showDescription { + backgroundView + } } - .padding(.bottom, .small) - .padding(.horizontal, .small) + .padding(.bottom, showDescription ? .small : .zero) + .padding(.horizontal, showDescription ? .small : .zero) } var backgroundView: some View { diff --git a/Sources/OversizeKit/StoreKit/Views/StoreProductView.swift b/Sources/OversizeKit/StoreKit/Views/StoreProductView.swift index 73ac63e..2fa0d08 100644 --- a/Sources/OversizeKit/StoreKit/Views/StoreProductView.swift +++ b/Sources/OversizeKit/StoreKit/Views/StoreProductView.swift @@ -47,7 +47,6 @@ public struct StoreProductView: View { } } - // Percentage of decrease = |239.88 - 59.99|/239.88 = 179.89/239.88 = 0.74991662497916 = 74.991662497916% var saleProcent: String { if let monthSubscriptionProduct { let yearPriceMonthly = monthSubscriptionProduct.price * 12 diff --git a/Sources/OversizeNoticeKit/NoticeListView.swift b/Sources/OversizeNoticeKit/NoticeListView.swift index 6c26da3..5916718 100644 --- a/Sources/OversizeNoticeKit/NoticeListView.swift +++ b/Sources/OversizeNoticeKit/NoticeListView.swift @@ -3,97 +3,97 @@ // NoticeListView.swift // -import Factory import OversizeKit +import OversizeNetwork import OversizeServices -import OversizeStoreService import OversizeUI import StoreKit import SwiftUI public struct NoticeListView: View { - @Injected(\.appStoreReviewService) var reviewService @Environment(\.isPremium) var isPremium: Bool + @StateObject private var viewModel = NoticeListViewModel() @State private var isBannerClosed = false - @State private var showRecommended = false - - private var specialOffer: StoreSpecialOfferEventType? { - var specialOffer: StoreSpecialOfferEventType? - for event in StoreSpecialOfferEventType.allCases where event.isNow { - if lastClosedSpecialOffer != event { - specialOffer = event - } - } - return specialOffer - } - @State private var isShowOfferSheet: Bool = false - @AppStorage("AppState.LastClosedSpecialOfferBanner") var lastClosedSpecialOffer: StoreSpecialOfferEventType = .oldUser - - private var isShowRate: Bool { - !isBannerClosed && reviewService.isShowReviewBanner - } - - private var isShowNoticeView: Bool { - isShowRate && (specialOffer != nil && isPremium == false) - } public init() {} public var body: some View { - if isShowNoticeView { + switch viewModel.state { + case let .result(offer: offer, isShowRate: isShowRate) where (offer != nil || isShowRate) && !isBannerClosed && !isPremium: VStack(spacing: .small) { - if isShowRate, let reviewUrl = Info.url.appStoreReview { - NoticeView("How do you like the application?") { - Link(destination: reviewUrl) { - Text("Good") - } - .buttonStyle(.primary(infinityWidth: true)) - .accent() - .simultaneousGesture(TapGesture().onEnded { - reviewService.estimate(goodRating: true) - isBannerClosed = true - }) + if isShowRate { + rateNoticeView + } + if let offer { + offerView(offer: offer) + } + } + case .initial, .loading, .error, .result, .empty: + EmptyView() + } + } - Button("Bad") { - reviewService.estimate(goodRating: false) - isBannerClosed = true - } - .buttonStyle(.tertiary(infinityWidth: true)) + @ViewBuilder + private var rateNoticeView: some View { + if let reviewUrl = Info.url.appStoreReview { + NoticeView("How do you like the \(Info.app.name ?? "app"))?") { + Link(destination: reviewUrl) { + Text("Good") + } + .buttonStyle(.primary(infinityWidth: true)) + .accent() + .simultaneousGesture(TapGesture().onEnded { + viewModel.reviewService.estimate(goodRating: true) + withAnimation { + isBannerClosed = true + } + }) - } closeAction: { - reviewService.rewiewBunnerClosed() + Button("Bad") { + viewModel.reviewService.estimate(goodRating: false) + withAnimation { isBannerClosed = true } - .animation(.default, value: isBannerClosed) } + .buttonStyle(.tertiary(infinityWidth: true)) - if let event = specialOffer { - let url = URL(string: "https://cdn.oversize.design/assets/illustrations/\(event.specialOfferImageURL)") + } closeAction: { + viewModel.reviewService.rewiewBunnerClosed() + withAnimation { + isBannerClosed = true + } + } + .animation(.default, value: isBannerClosed) + } + } - NoticeView(event.specialOfferBannerTitle, - subtitle: event.specialOfferDescription, - imageURL: url) - { - Button { - isShowOfferSheet.toggle() - } label: { - Text("Get Free Trial") - } - .accent() + @ViewBuilder + private func offerView(offer: Components.Schemas.SpecialOffer) -> some View { + if let imageUrl = offer.imageURL, let url = URL(string: imageUrl) { + NoticeView( + viewModel.textPrepere(offer.title), + subtitle: viewModel.textPrepere(offer.description ?? ""), + imageURL: url + ) { + Button { + isShowOfferSheet.toggle() + } label: { + Text("Accept Offer") + } + .accent() - } closeAction: { - lastClosedSpecialOffer = event - } - .sheet(isPresented: $isShowOfferSheet) { - StoreSpecialOfferView(event: event) - .systemServices() - } + } closeAction: { + viewModel.lastClosedSpecialOffer = offer.id + withAnimation { + isBannerClosed = true } } - } else { - EmptyView() + .sheet(isPresented: $isShowOfferSheet) { + StoreSpecialOfferView(event: offer) + .systemServices() + } } } } diff --git a/Sources/OversizeNoticeKit/NoticeListViewModel.swift b/Sources/OversizeNoticeKit/NoticeListViewModel.swift new file mode 100644 index 0000000..0862c48 --- /dev/null +++ b/Sources/OversizeNoticeKit/NoticeListViewModel.swift @@ -0,0 +1,107 @@ +// +// Copyright © 2023 Alexander Romanov +// NoticeListViewModel.swift, created on 25.12.2023 +// + +import Factory +import OversizeModels +import OversizeNetwork +import OversizeServices +import OversizeStoreService +import StoreKit +import SwiftUI + +@MainActor +public final class NoticeListViewModel: ObservableObject { + enum State { + case initial + case loading + case result(offer: Components.Schemas.SpecialOffer?, isShowRate: Bool) + case empty + case error(AppError) + } + + @Injected(\.appStoreReviewService) var reviewService + @Injected(\.networkService) var networkService + @Injected(\.storeKitService) var storeKitService: StoreKitService + + var isShowReviewBanner: Bool { + reviewService.isShowReviewBanner + } + + @AppStorage("AppState.LastClosedSpecialOfferBanner") var lastClosedSpecialOffer: String = "" + + private let expectedFormat = Date.ISO8601FormatStyle() + + @Published var state = State.initial + @Published public var trialDaysPeriodText: String = "" + @Published public var salePercent: Decimal = 0 + + public init() { + Task { + await fetchData() + } + } + + public func fetchData() async { + state = .loading + await fetchStoreKitProudcts() + await fetchAndSetSpecialOffer() + } + + public func fetchStoreKitProudcts() async { + let result = await storeKitService.requestProducts() + switch result { + case let .success(products): + if let product = products.autoRenewable.first(where: { $0.isOffer }), let offer = product.subscription?.introductoryOffer { + trialDaysPeriodText = storeKitService.daysLabel(offer.period.value, unit: offer.period.unit) + salePercent = storeKitService.salePercent(product: product, products: products) + } + case .failure: + break + } + } + + public func fetchAndSetSpecialOffer() async { + let result = await networkService.fetchSpecialOffers() + switch result { + case let .success(offers): + if let offer = offers.first(where: { checkDateInSelectedPeriod(startDate: $0.startDate, endDate: $0.endDate) }) { + if offer.id != lastClosedSpecialOffer { + withAnimation { + state = .result( + offer: offer, + isShowRate: isShowReviewBanner + ) + } + } else if isShowReviewBanner { + withAnimation { + state = .result( + offer: nil, + isShowRate: isShowReviewBanner + ) + } + } else { + state = .empty + } + } + case .failure: + break + } + } + + private func checkDateInSelectedPeriod(startDate: Date, endDate: Date) -> Bool { + if startDate < endDate { + return (startDate ... endDate).contains(Date()) + } else { + return false + } + } + + func textPrepere(_ text: String) -> String { + text + .replacingOccurrences(of: "", with: salePercent.toString) + .replacingOccurrences(of: "", with: trialDaysPeriodText) + .replacingOccurrences(of: "", with: Info.store.subscriptionsName) + } +}