[CHANGE] Overhaul of the AppDetailView with version history, reviews & ratings, and app information

This commit is contained in:
Fabian Thies
2023-01-31 22:38:42 +01:00
parent 3f06a53058
commit e85876cd24
10 changed files with 395 additions and 69 deletions

View File

@@ -55,16 +55,24 @@ internal enum L10n {
internal static let restoreBackup = L10n.tr("Localizable", "AppAction.restoreBackup", fallback: "Restore backup")
}
internal enum AppDetailView {
///
internal static let information = L10n.tr("Localizable", "AppDetailView.information", fallback: "")
/// More...
internal static let more = L10n.tr("Localizable", "AppDetailView.more", fallback: "More...")
/// The app requires no permissions.
internal static let noPermissions = L10n.tr("Localizable", "AppDetailView.noPermissions", fallback: "The app requires no permissions.")
/// No screenshots available for this app.
internal static let noScreenshots = L10n.tr("Localizable", "AppDetailView.noScreenshots", fallback: "No screenshots available for this app.")
/// No version information
internal static let noVersionInformation = L10n.tr("Localizable", "AppDetailView.noVersionInformation", fallback: "No version information")
/// Permissions
internal static let permissions = L10n.tr("Localizable", "AppDetailView.permissions", fallback: "Permissions")
/// Version
internal static let version = L10n.tr("Localizable", "AppDetailView.version", fallback: "Version")
/// Ratings & Reviews
internal static let reviews = L10n.tr("Localizable", "AppDetailView.reviews", fallback: "Ratings & Reviews")
/// Version %@
internal static func version(_ p1: Any) -> String {
return L10n.tr("Localizable", "AppDetailView.version", String(describing: p1), fallback: "Version %@")
}
/// What's New
internal static let whatsNew = L10n.tr("Localizable", "AppDetailView.whatsNew", fallback: "What's New")
internal enum Badge {
@@ -73,6 +81,46 @@ internal enum L10n {
/// From Trusted Source
internal static let trusted = L10n.tr("Localizable", "AppDetailView.Badge.trusted", fallback: "From Trusted Source")
}
internal enum Information {
/// Compatibility
internal static let compatibility = L10n.tr("Localizable", "AppDetailView.Information.compatibility", fallback: "Compatibility")
/// Requires iOS %@ or higher
internal static func compatibilityAtLeast(_ p1: Any) -> String {
return L10n.tr("Localizable", "AppDetailView.Information.compatibilityAtLeast", String(describing: p1), fallback: "Requires iOS %@ or higher")
}
/// Requires iOS %@ or lower
internal static func compatibilityOrLower(_ p1: Any) -> String {
return L10n.tr("Localizable", "AppDetailView.Information.compatibilityOrLower", String(describing: p1), fallback: "Requires iOS %@ or lower")
}
/// Unknown
internal static let compatibilityUnknown = L10n.tr("Localizable", "AppDetailView.Information.compatibilityUnknown", fallback: "Unknown")
/// Developer
internal static let developer = L10n.tr("Localizable", "AppDetailView.Information.developer", fallback: "Developer")
/// Latest Version
internal static let latestVersion = L10n.tr("Localizable", "AppDetailView.Information.latestVersion", fallback: "Latest Version")
/// Size
internal static let size = L10n.tr("Localizable", "AppDetailView.Information.size", fallback: "Size")
/// Source
internal static let source = L10n.tr("Localizable", "AppDetailView.Information.source", fallback: "Source")
}
internal enum Reviews {
/// out of %d
internal static func outOf(_ p1: Int) -> String {
return L10n.tr("Localizable", "AppDetailView.Reviews.outOf", p1, fallback: "out of %d")
}
/// %d Ratings
internal static func ratings(_ p1: Int) -> String {
return L10n.tr("Localizable", "AppDetailView.Reviews.ratings", p1, fallback: "%d Ratings")
}
/// See All
internal static let seeAll = L10n.tr("Localizable", "AppDetailView.Reviews.seeAll", fallback: "See All")
}
internal enum WhatsNew {
/// Show project on GitHub
internal static let showOnGithub = L10n.tr("Localizable", "AppDetailView.WhatsNew.showOnGithub", fallback: "Show project on GitHub")
/// Version History
internal static let versionHistory = L10n.tr("Localizable", "AppDetailView.WhatsNew.versionHistory", fallback: "Version History")
}
}
internal enum AppIDsView {
/// Each app and app extension installed with SideStore must register an App ID with Apple.

View File

@@ -16,6 +16,20 @@ struct DateFormatterHelper {
dateComponentsFormatter.collapsesLargestUnit = false
return dateComponentsFormatter
}()
private static let relativeDateFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter
}()
private static let mediumDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .none
return dateFormatter
}()
static func string(forExpirationDate date: Date) -> String {
@@ -32,10 +46,11 @@ struct DateFormatterHelper {
self.appExpirationDateFormatter.unitsStyle = .full
self.appExpirationDateFormatter.allowedUnits = [.day]
}
return self.appExpirationDateFormatter.string(from: startDate, to: date) ?? ""
}
return self.appExpirationDateFormatter.string(from: startDate, to: date) ?? ""
}
static func string(forRelativeDate date: Date, to referenceDate: Date = Date()) -> String {
self.relativeDateFormatter.localizedString(for: date, relativeTo: referenceDate)
}
}

View File

@@ -119,12 +119,28 @@
/* AppDetailView*/
"AppDetailView.Badge.official" = "Official App";
"AppDetailView.Badge.trusted" = "From Trusted Source";
"AppDetailView.noScreenshots" = "No screenshots available for this app.";
"AppDetailView.more" = "More...";
"AppDetailView.whatsNew" = "What's New";
"AppDetailView.version" = "Version";
"AppDetailView.WhatsNew.versionHistory" = "Version History";
"AppDetailView.WhatsNew.showOnGithub" = "Show project on GitHub";
"AppDetailView.reviews" = "Ratings & Reviews";
"AppDetailView.Reviews.outOf" = "out of %d";
"AppDetailView.Reviews.ratings" = "%d Ratings";
"AppDetailView.Reviews.seeAll" = "See All";
"AppDetailView.version" = "Version %@";
"AppDetailView.noVersionInformation" = "No version information";
"AppDetailView.noPermissions" = "The app requires no permissions.";
"AppDetailView.permissions" = "Permissions";
"AppDetailView.information" = "Information";
"AppDetailView.Information.source" = "Source";
"AppDetailView.Information.developer" = "Developer";
"AppDetailView.Information.size" = "Size";
"AppDetailView.Information.latestVersion" = "Latest Version";
"AppDetailView.Information.compatibility" = "Compatibility";
"AppDetailView.Information.compatibilityUnknown" = "Unknown";
"AppDetailView.Information.compatibilityAtLeast" = "Requires iOS %@ or higher";
"AppDetailView.Information.compatibilityOrLower" = "Requires iOS %@ or lower";
/* AppPermissionGrid */
"AppPermissionGrid.usageDescription" = "Usage Description";

View File

@@ -16,14 +16,7 @@ struct AppDetailView: View {
let storeApp: StoreApp
var dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .none
return dateFormatter
}()
var byteCountFormatter: ByteCountFormatter = {
let byteCountFormatter: ByteCountFormatter = {
let formatter = ByteCountFormatter()
return formatter
}()
@@ -93,38 +86,81 @@ struct AppDetailView: View {
}
var contentView: some View {
VStack(alignment: .leading, spacing: 32) {
if storeApp.sourceIdentifier == Source.altStoreIdentifier {
officialAppBadge
}
if let subtitle = storeApp.subtitle {
Text(subtitle)
.multilineTextAlignment(.center)
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 32) {
if storeApp.sourceIdentifier == Source.altStoreIdentifier {
officialAppBadge
}
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)
}
if !storeApp.screenshotURLs.isEmpty {
// Equatable: Only reload the view if the screenshots change.
// This prevents unnecessary redraws on scroll.
AppScreenshotsScrollView(urls: storeApp.screenshotURLs)
.equatable()
VStack(spacing: 16) {
Divider()
currentVersionView
Divider()
ratingsView
Divider()
permissionsView
Divider()
informationView
}
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(.horizontal)
}
.padding(.vertical)
.background(
@@ -156,25 +192,26 @@ struct AppDetailView: View {
var currentVersionView: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .bottom) {
VStack(alignment: .leading) {
VStack {
HStack(alignment: .firstTextBaseline) {
Text(L10n.AppDetailView.whatsNew)
.bold()
.font(.title3)
if let version = storeApp.latestVersion?.version {
Text("\(L10n.AppDetailView.version) \(version)")
.font(.callout)
.foregroundColor(.secondary)
Spacer()
NavigationLink {
AppVersionHistoryView(storeApp: self.storeApp)
} label: {
Text(L10n.AppDetailView.WhatsNew.versionHistory)
}
}
Spacer()
if let versionDate = storeApp.versionDate, let versionSize = storeApp.size {
VStack(alignment: .trailing) {
Text(dateFormatter.string(from: versionDate))
Text(byteCountFormatter.string(fromByteCount: Int64(versionSize)))
if let latestVersion = storeApp.latestVersion {
HStack {
Text(L10n.AppDetailView.version(latestVersion.version))
Spacer()
Text(DateFormatterHelper.string(forRelativeDate: latestVersion.date))
}
.font(.callout)
.foregroundColor(.secondary)
@@ -189,6 +226,109 @@ struct AppDetailView: View {
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..<rating) { _ in
Image(systemSymbol: .starFill)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 8)
}
}
ProgressView(value: 0.5)
.frame(maxWidth: .infinity)
.progressViewStyle(LinearProgressViewStyle(tint: .secondary))
}
}
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
Text(L10n.AppDetailView.Reviews.ratings(5))
.font(.callout)
.foregroundColor(.secondary)
}
}
TabView {
ForEach(0..<5) { i in
HintView(backgroundColor: Color(UIColor.secondarySystemBackground)) {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Review \(i + 1)")
.bold()
.lineLimit(1)
Spacer()
Text(DateFormatterHelper.string(forRelativeDate: Date().addingTimeInterval(-60*60)))
.foregroundColor(.secondary)
}
RatingStars(rating: i + 1)
.frame(height: 12)
.foregroundColor(.yellow)
}
ExpandableText(text: "Long review text content here.\nMultiple lines.\nAt least three are shown.\nBut are there more?")
.lineLimit(3)
.expandButton(TextSet(text: L10n.AppDetailView.more, font: .callout, color: .accentColor))
}
.frame(maxWidth: .infinity)
}
.tag(i)
.padding(.horizontal, 16)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.frame(height: 150)
.padding(.horizontal, -16)
}
}
@@ -209,4 +349,53 @@ struct AppDetailView: View {
Spacer()
}
}
var informationData: [(title: String, content: String)] {
var data: [(title: String, content: String)] = [
(L10n.AppDetailView.Information.source, self.storeApp.source?.name ?? ""),
(L10n.AppDetailView.Information.developer, self.storeApp.developerName),
// ("Category", self.storeApp.category),
]
if let latestVersion = self.storeApp.latestVersion {
data += [
(L10n.AppDetailView.Information.size, self.byteCountFormatter.string(fromByteCount: latestVersion.size)),
(L10n.AppDetailView.Information.latestVersion, self.storeApp.latestVersion?.version ?? ""),
]
var compatibility: String = L10n.AppDetailView.Information.compatibilityUnknown
let iOSVersion = ProcessInfo.processInfo.operatingSystemVersion
if let minOSVersion = latestVersion.minOSVersion, ProcessInfo.processInfo.isOperatingSystemAtLeast(minOSVersion) == false {
compatibility = L10n.AppDetailView.Information.compatibilityAtLeast(minOSVersion.stringValue)
}
if let maxOSVersion = latestVersion.maxOSVersion,
(!ProcessInfo.processInfo.isOperatingSystemAtLeast(maxOSVersion) || maxOSVersion.stringValue.compare(iOSVersion.stringValue, options: .numeric) == .orderedSame) {
compatibility = L10n.AppDetailView.Information.compatibilityOrLower(maxOSVersion.stringValue)
}
data.append((L10n.AppDetailView.Information.compatibility, compatibility))
}
return data
}
var informationView: some View {
VStack(alignment: .leading) {
Text(L10n.AppDetailView.information)
.bold()
.font(.title3)
LazyVGrid(columns: [GridItem(.flexible(), alignment: .leading), GridItem(.flexible(), alignment: .trailing)], spacing: 8) {
ForEach(informationData, id: \.title) { title, content in
Text(title)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(content)
.multilineTextAlignment(.trailing)
}
}
}
}
}

View File

@@ -43,6 +43,8 @@ struct AppScreenshotsPreview: View {
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.navigationTitle("\(index + 1) of \(self.urls.count)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
SwiftUI.Button {

View File

@@ -0,0 +1,49 @@
//
// AppVersionHistoryView.swift
// SideStore
//
// Created by Fabian Thies on 28.01.23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
import AltStoreCore
import ExpandableText
struct AppVersionHistoryView: View {
let storeApp: StoreApp
var body: some View {
List {
ForEach(storeApp.versions.sorted(by: { $0.date < $1.date }), id: \.version) { version in
VStack(spacing: 8) {
HStack {
Text(version.version).bold()
Spacer()
Text(DateFormatterHelper.string(forRelativeDate: version.date))
.foregroundColor(.secondary)
}
if let versionDescription = version.localizedDescription {
ExpandableText(text: versionDescription)
.lineLimit(3)
.expandButton(TextSet(text: L10n.AppDetailView.more, font: .callout, color: .accentColor))
.buttonStyle(.plain)
} else {
Text("No version desciption available")
.italic()
.foregroundColor(.secondary)
}
}
}
}
.listStyle(PlainListStyle())
.navigationTitle("Version History")
}
}
//struct AppVersionHistoryView_Previews: PreviewProvider {
// static var previews: some View {
// AppVersionHistoryView(storeApp: )
// }
//}

View File

@@ -50,13 +50,15 @@ struct NewsItemView: View {
.bold()
.foregroundColor(.white)
VStack(alignment: .leading) {
HStack(spacing: 0) {
if let sourceName = newsItem.source?.name {
Text(sourceName)
.italic()
}
if let externalURL = newsItem.externalURL {
Text(" • ")
HStack(spacing: 0) {
Image(systemSymbol: .link)
Text(externalURL.host ?? "")

View File

@@ -61,8 +61,10 @@ struct RootView: View {
}
}
.padding()
.background(Color(UIColor.altPrimary))
.foregroundColor(.white)
.background(Color.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(radius: 15)
}
Spacer()
@@ -83,7 +85,7 @@ extension RootView {
switch self {
case .news: return .newspaper
case .browse: return .booksVertical
case .myApps: return .appBadge
case .myApps: return .squareStack
case .settings: return .gearshape
}
}