// // AppDetailView.swift // SideStore // // Created by Fabian Thies on 18.11.22. // Copyright © 2022 Fabian Thies. All rights reserved. // import SwiftUI import AsyncImage import ExpandableText import SFSafeSymbols import AltStoreCore struct AppDetailView: View { let storeApp: StoreApp let byteCountFormatter: ByteCountFormatter = { let formatter = ByteCountFormatter() return formatter }() @State var scrollOffset: CGFloat = .zero let maxContentCornerRadius: CGFloat = 24 let headerViewHeight: CGFloat = 140 let permissionColumns = 4 var headerBlurRadius: CGFloat { min(20, max(0, 20 - (scrollOffset / -150) * 20)) } var isHeaderViewVisible: Bool { scrollOffset < headerViewHeight + 12 } var contentCornerRadius: CGFloat { max(CGFloat.zero, min(maxContentCornerRadius, maxContentCornerRadius * (1 - self.scrollOffset / self.headerViewHeight))) } var canRateApp: Bool { self.storeApp.installedApp != nil } var body: some View { ObservableScrollView(scrollOffset: $scrollOffset) { proxy in LazyVStack { headerView .frame(height: headerViewHeight) contentView } } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { AppPillButton(app: storeApp) .disabled(isHeaderViewVisible) .offset(y: isHeaderViewVisible ? 12 : 0) .opacity(isHeaderViewVisible ? 0 : 1) .animation(.easeInOut(duration: 0.2), value: isHeaderViewVisible) } ToolbarItemGroup(placement: .principal) { HStack { Spacer() AppIconView(iconUrl: storeApp.iconURL, isSideStore: storeApp.isSideStore, size: 24) Text(storeApp.name) .bold() Spacer() } .offset(y: isHeaderViewVisible ? 12 : 0) .opacity(isHeaderViewVisible ? 0 : 1) .animation(.easeInOut(duration: 0.2), value: isHeaderViewVisible) } } } var headerView: some View { ZStack(alignment: .center) { GeometryReader { proxy in AppIconView(iconUrl: storeApp.iconURL, isSideStore: storeApp.isSideStore, size: proxy.frame(in: .global).width) .blur(radius: headerBlurRadius) .offset(y: min(0, scrollOffset)) } .padding() AppRowView(app: storeApp) .padding(.horizontal) } } var contentView: some View { VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 32) { if storeApp.isFromOfficialSource { officialAppBadge } else if storeApp.isFromTrustedSource { trustedAppBadge } if let subtitle = storeApp.subtitle { VStack { if #available(iOS 15.0, *) { Image(systemSymbol: .quoteOpening) .foregroundColor(.secondary.opacity(0.5)) .imageScale(.large) .transformEffect(CGAffineTransform(a: 1, b: 0, c: -0.3, d: 1, tx: 0, ty: 0)) .frame(maxWidth: .infinity, alignment: .leading) .offset(x: 30) } Text(subtitle) .bold() .italic() .multilineTextAlignment(.center) .frame(maxWidth: .infinity) if #available(iOS 15.0, *) { Image(systemSymbol: .quoteClosing) .foregroundColor(.secondary.opacity(0.5)) .imageScale(.large) .transformEffect(CGAffineTransform(a: 1, b: 0, c: -0.3, d: 1, tx: 0, ty: 0)) .frame(maxWidth: .infinity, alignment: .trailing) .offset(x: -30) } } .padding(.horizontal) } if !storeApp.screenshotURLs.isEmpty { // Equatable: Only reload the view if the screenshots change. // This prevents unnecessary redraws on scroll. AppScreenshotsScrollView(urls: storeApp.screenshotURLs) .equatable() } else { VStack() { Text(L10n.AppDetailView.noScreenshots) .italic() .foregroundColor(.secondary) } .frame(maxWidth: .infinity) .padding(.horizontal) } ExpandableText(text: storeApp.localizedDescription) .lineLimit(6) .expandButton(TextSet(text: L10n.AppDetailView.more, font: .callout, color: .accentColor)) .padding(.horizontal) } VStack(spacing: 24) { Divider() currentVersionView Divider() ratingsView Divider() permissionsView Divider() informationView if !(storeApp.isFromOfficialSource || storeApp.isFromTrustedSource) { Divider() reportButton } } .padding(.horizontal) } .padding(.vertical) .background( RoundedRectangle(cornerRadius: contentCornerRadius) .foregroundColor(Color(UIColor.systemBackground)) .shadow(radius: isHeaderViewVisible ? 12 : 0) ) } var officialAppBadge: some View { HintView(backgroundColor: Color(UIColor.secondarySystemBackground)) { HStack { Spacer() Image(systemSymbol: .checkmarkSealFill) Text(L10n.AppDetailView.Badge.official) Spacer() } .foregroundColor(.accentColor) } .padding(.horizontal) } var trustedAppBadge: some View { HintView(backgroundColor: Color(UIColor.secondarySystemBackground)) { HStack { Spacer() Image(systemSymbol: .shieldLefthalfFill) Text(L10n.AppDetailView.Badge.trusted) Spacer() } .foregroundColor(.accentColor) } .padding(.horizontal) } var currentVersionView: some View { VStack(alignment: .leading, spacing: 8) { VStack { HStack(alignment: .firstTextBaseline) { Text(L10n.AppDetailView.whatsNew) .bold() .font(.title3) Spacer() NavigationLink { AppVersionHistoryView(storeApp: self.storeApp) } label: { Text(L10n.AppDetailView.WhatsNew.versionHistory) } } if let latestVersion = storeApp.latestVersion { HStack { Text(L10n.AppDetailView.version(latestVersion.version)) Spacer() Text(DateFormatterHelper.string(forRelativeDate: latestVersion.date)) } .font(.callout) .foregroundColor(.secondary) } } if let versionDescription = storeApp.versionDescription { ExpandableText(text: versionDescription) .lineLimit(5) .expandButton(TextSet(text: L10n.AppDetailView.more, font: .callout, color: .accentColor)) } else { Text(L10n.AppDetailView.noVersionInformation) .foregroundColor(.secondary) } if true { SwiftUI.Button { UIApplication.shared.open(URL(string: "https://github.com/SideStore/SideStore")!) { _ in } } label: { HStack { Text(L10n.AppDetailView.WhatsNew.showOnGithub) Image(systemSymbol: .arrowUpForwardSquare) } } } } } var ratingsView: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline) { Text(L10n.AppDetailView.whatsNew) .bold() .font(.title3) Spacer() NavigationLink { AppVersionHistoryView(storeApp: self.storeApp) } label: { Text(L10n.AppDetailView.Reviews.seeAll) } } HStack(spacing: 40) { VStack { Text("3.0") .font(.system(size: 48, weight: .bold, design: .rounded)) .opacity(0.8) Text(L10n.AppDetailView.Reviews.outOf(5)) .bold() .font(.callout) .foregroundColor(.secondary) } VStack(alignment: .trailing) { LazyVGrid(columns: [GridItem(.fixed(48), alignment: .trailing), GridItem(.flexible())], spacing: 2) { ForEach(Array(1...5).reversed(), id: \.self) { rating in HStack(spacing: 2) { ForEach(0..