mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 06:43:25 +01:00
213 lines
6.9 KiB
Swift
213 lines
6.9 KiB
Swift
//
|
|
// 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
|
|
|
|
var dateFormatter: DateFormatter = {
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateStyle = .medium
|
|
dateFormatter.timeStyle = .none
|
|
return dateFormatter
|
|
}()
|
|
|
|
var 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 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, 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, 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: 32) {
|
|
if storeApp.sourceIdentifier == Source.altStoreIdentifier {
|
|
officialAppBadge
|
|
}
|
|
|
|
if let subtitle = storeApp.subtitle {
|
|
Text(subtitle)
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: .infinity)
|
|
.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()
|
|
}
|
|
|
|
ExpandableText(text: storeApp.localizedDescription)
|
|
.lineLimit(6)
|
|
.expandButton(TextSet(text: L10n.AppDetailView.more, font: .callout, color: .accentColor))
|
|
.padding(.horizontal)
|
|
|
|
currentVersionView
|
|
.padding(.horizontal)
|
|
|
|
permissionsView
|
|
.padding(.horizontal)
|
|
|
|
// TODO: Add review view
|
|
// Only let users rate the app if it is currently installed!
|
|
}
|
|
.padding(.vertical)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: contentCornerRadius)
|
|
.foregroundColor(Color(UIColor.systemBackground))
|
|
.shadow(radius: isHeaderViewVisible ? 12 : 0)
|
|
)
|
|
}
|
|
|
|
var officialAppBadge: some View {
|
|
HStack {
|
|
Spacer()
|
|
Image(systemSymbol: .checkmarkSealFill)
|
|
Text("Official App")
|
|
Spacer()
|
|
}
|
|
.foregroundColor(.accentColor)
|
|
}
|
|
|
|
var trustedAppBadge: some View {
|
|
HStack {
|
|
Spacer()
|
|
Image(systemSymbol: .shieldLefthalfFill)
|
|
Text("From Trusted Source")
|
|
Spacer()
|
|
}
|
|
.foregroundColor(.accentColor)
|
|
}
|
|
|
|
var currentVersionView: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(alignment: .bottom) {
|
|
VStack(alignment: .leading) {
|
|
Text(L10n.AppDetailView.whatsNew)
|
|
.bold()
|
|
.font(.title3)
|
|
|
|
if let version = storeApp.latestVersion?.version {
|
|
Text("\(L10n.AppDetailView.version) \(version)")
|
|
.font(.callout)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if let versionDate = storeApp.versionDate, let versionSize = storeApp.size {
|
|
VStack(alignment: .trailing) {
|
|
Text(dateFormatter.string(from: versionDate))
|
|
Text(byteCountFormatter.string(fromByteCount: Int64(versionSize)))
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
var permissionsView: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(L10n.AppDetailView.permissions)
|
|
.bold()
|
|
.font(.title3)
|
|
|
|
if storeApp.permissions.isEmpty {
|
|
Text(L10n.AppDetailView.noPermissions)
|
|
.font(.callout)
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
AppPermissionsGrid(permissions: storeApp.permissions)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|