Reorganize AltStore project into UIKit and SwiftUI folders

This commit is contained in:
naturecodevoid
2023-05-20 12:35:53 -07:00
parent e06cca8224
commit 2db073d2c5
115 changed files with 41 additions and 25 deletions

View File

@@ -0,0 +1,81 @@
//
// SwiftUIView.swift
// SideStore
//
// Created by Fabian Thies on 18.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import AsyncImage
struct AppIconView: View {
@ObservedObject private var iO = Inject.observer
@ObservedObject private var sideStoreIconData = AppIconsData.shared
let iconUrl: URL?
var isSideStore: Bool
var size: CGFloat = 64
var cornerRadius: CGFloat {
size * 0.234
}
var image: some View {
if isSideStore {
return AnyView(
Image(uiImage: UIImage(named: sideStoreIconData.selectedIconName! + "-image") ?? UIImage())
.resizable()
.renderingMode(.original)
)
}
if let iconUrl {
return AnyView(
AsyncImage(url: iconUrl) { image in
image
.resizable()
} placeholder: {
Color(UIColor.secondarySystemBackground)
}
)
}
return AnyView(Color(UIColor.secondarySystemBackground))
}
var body: some View {
image
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.enableInjection()
}
}
extension AppIconView: Equatable {
/// Prevent re-rendering of the view if the parameters didn't change
static func == (lhs: AppIconView, rhs: AppIconView) -> Bool {
lhs.iconUrl == rhs.iconUrl && lhs.cornerRadius == rhs.cornerRadius
}
}
import AltStoreCore
struct AppIconView_Previews: PreviewProvider {
static let context = DatabaseManager.shared.viewContext
static let app = StoreApp.makeAltStoreApp(in: context)
static var previews: some View {
HStack {
AppIconView(iconUrl: app.iconURL, isSideStore: true)
VStack(alignment: .leading) {
Text(app.name)
.bold()
Text(app.developerName)
.font(.callout)
.foregroundColor(.secondary)
}
}
}
}

View File

@@ -0,0 +1,144 @@
//
// AppPillButton.swift
// SideStore
//
// Created by Fabian Thies on 20.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import AltStoreCore
struct AppPillButton: View {
@ObservedObject
var appManager = AppManager.shared.publisher
let app: AppProtocol
var showRemainingDays = false
var storeApp: StoreApp? {
(app as? StoreApp) ?? (app as? InstalledApp)?.storeApp
}
var installedApp: InstalledApp? {
(app as? InstalledApp) ?? (app as? StoreApp)?.installedApp
}
var progress: Progress? {
appManager.refreshProgress[app.bundleIdentifier] ?? appManager.installationProgress[app.bundleIdentifier]
}
// let progress = {
// let progress = Progress(totalUnitCount: 100)
// progress.completedUnitCount = 20
// return progress
// }()
var buttonText: String {
// guard progress == nil else {
// return ""
// }
if let installedApp {
if self.showRemainingDays {
return DateFormatterHelper.string(forExpirationDate: installedApp.expirationDate)
}
return L10n.AppPillButton.open
}
return L10n.AppPillButton.free
}
var body: some View {
SwiftUI.Button(action: handleButton) {
Text(buttonText.uppercased())
.bold()
}
.buttonStyle(PillButtonStyle(tintColor: storeApp?.tintColor ?? .black, progress: progress))
}
func handleButton() {
if let installedApp {
if showRemainingDays {
self.refreshApp(installedApp)
} else {
self.openApp(installedApp)
}
} else if let storeApp {
self.installApp(storeApp)
}
}
func openApp(_ installedApp: InstalledApp) {
UIApplication.shared.open(installedApp.openAppURL)
}
func refreshApp(_ installedApp: InstalledApp) {
AppManager.shared.refresh([installedApp], presentingViewController: nil)
}
func installApp(_ storeApp: StoreApp) {
let previousProgress = AppManager.shared.installationProgress(for: storeApp)
guard previousProgress == nil else {
previousProgress?.cancel()
return
}
let _ = AppManager.shared.install(storeApp, presentingViewController: UIApplication.shared.keyWindow?.rootViewController) { result in
switch result {
case let .success(installedApp):
print("Installed app: \(installedApp.bundleIdentifier)")
case let .failure(error):
print("Failed to install app: \(error.localizedDescription)")
NotificationManager.shared.reportError(error: error)
AppManager.shared.installationProgress(for: storeApp)?.cancel()
}
}
}
}
struct AppPillButton_Previews: PreviewProvider {
static let context = DatabaseManager.shared.viewContext
static let app = StoreApp.makeAltStoreApp(in: context)
static let installedApp = InstalledApp.fetchAltStore(in: context)
static var previews: some View {
VStack {
self.preview(for: app)
self.preview(for: installedApp!)
self.preview(for: installedApp!, showRemainingDays: true)
}
.padding()
}
@ViewBuilder
static func preview(for app: AppProtocol, showRemainingDays: Bool = false) -> some View {
HintView(backgroundColor: Color(UIColor.secondarySystemBackground)) {
HStack {
AppIconView(iconUrl: self.app.iconURL, isSideStore: true)
VStack(alignment: .leading) {
Text(app is StoreApp ? "Store App" : "Installed App")
.bold()
Text(
app is StoreApp ?
"Can be installed" :
showRemainingDays ? "Can be refreshed" : "Can be opened"
)
.font(.callout)
.foregroundColor(.secondary)
}
Spacer()
AppPillButton(app: app, showRemainingDays: showRemainingDays)
}
}
}
}

View File

@@ -0,0 +1,55 @@
//
// AppRowView.swift
// SideStore
//
// Created by Fabian Thies on 18.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import AltStoreCore
struct AppRowView: View {
let app: AppProtocol
var storeApp: StoreApp? {
(app as? StoreApp) ?? (app as? InstalledApp)?.storeApp
}
var showRemainingDays: Bool = false
var body: some View {
HStack(alignment: .center, spacing: 12) {
AppIconView(iconUrl: storeApp?.iconURL, isSideStore: storeApp?.isSideStore ?? false)
VStack(alignment: .leading, spacing: 2) {
Text(app.name)
.bold()
Text(storeApp?.developerName ?? L10n.AppRowView.sideloaded)
.font(.callout)
.foregroundColor(.secondary)
if false {
RatingStars(rating: 4)
.frame(height: 12)
.foregroundColor(.secondary)
}
}
.lineLimit(1)
Spacer()
AppPillButton(app: app, showRemainingDays: showRemainingDays)
}
.padding()
.tintedBackground(Color(storeApp?.tintColor ?? UIColor(Color.accentColor)))
.clipShape(RoundedRectangle(cornerRadius: 24, style: .circular))
}
}
//struct AppRowView_Previews: PreviewProvider {
// static var previews: some View {
// AppRowView()
// }
//}

View File

@@ -0,0 +1,55 @@
//
// AppScreenshot.swift
// SideStore
//
// Created by Fabian Thies on 20.12.22.
// Copyright © 2022 SideStore. All rights reserved.
//
import SwiftUI
import UIKit
import AsyncImage
struct AppScreenshot: View {
let url: URL
var aspectRatio: CGFloat = 9/16
static let processor = Self.ScreenshotProcessor()
var body: some View {
AsyncImage(url: self.url, processor: Self.processor) { image in
image
.resizable()
} placeholder: {
Rectangle()
.foregroundColor(.secondary)
}
.aspectRatio(self.aspectRatio, contentMode: .fit)
.cornerRadius(8)
}
}
extension AppScreenshot {
class ScreenshotProcessor: ImageProcessor {
func process(image: UIImage) -> UIImage {
guard let cgImage = image.cgImage, image.size.width > image.size.height else { return image }
let rotatedImage = UIImage(cgImage: cgImage, scale: image.scale, orientation: .right)
return rotatedImage
}
}
}
import AltStoreCore
struct AppScreenshot_Previews: PreviewProvider {
static let context = DatabaseManager.shared.viewContext
static let app = StoreApp.makeAltStoreApp(in: context)
static var previews: some View {
AppScreenshot(url: app.screenshotURLs[0])
.padding()
}
}

View File

@@ -0,0 +1,118 @@
//
// AsyncFallibleButton.swift
// SideStore
//
// Created by naturecodevoid on 2/18/23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
private enum AsyncFallibleButtonState {
case none
case loading
case success
case error
}
struct AsyncFallibleButton<Label: View>: View {
@ObservedObject private var iO = Inject.observer
let action: () throws -> Void
let label: (_ execute: @escaping () -> Void) -> Label
var afterFinish: (_ success: Bool) -> Void = { success in } // runs after the checkmark/X has disappeared
var wrapInButton = true
var secondsToDisplayResultIcon: Double = 3
@State private var state: AsyncFallibleButtonState = .none
@State private var showErrorAlert = false
@State private var errorAlertMessage = ""
private var inside: some View {
HStack {
label(execute)
if state != .none {
if wrapInButton {
Spacer()
}
switch (state) {
case .loading:
ProgressView()
case .success:
Image(systemSymbol: .checkmark)
.foregroundColor(Color.green)
case .error:
Image(systemSymbol: .xmark)
.foregroundColor(Color.red)
default:
Image(systemSymbol: .questionmark)
.foregroundColor(Color.yellow)
}
}
}
}
private var wrapped: some View {
if wrapInButton {
return AnyView(SwiftUI.Button(action: {
execute()
}) {
inside
})
} else {
return AnyView(inside)
}
}
var body: some View {
wrapped
.alert(isPresented: $showErrorAlert) {
Alert(
title: Text(L10n.AsyncFallibleButton.error),
message: Text(errorAlertMessage)
)
}
.disabled(state != .none)
.animation(.default, value: state)
.enableInjection()
}
func execute() {
if state != .none { return }
state = .loading
DispatchQueue.global().async {
do {
try action()
DispatchQueue.main.async { state = .success }
} catch {
DispatchQueue.main.async {
state = .error
errorAlertMessage = (error as? LocalizedError)?.failureReason ?? error.localizedDescription
showErrorAlert = true
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + secondsToDisplayResultIcon) {
let lastState = state
state = .none
afterFinish(lastState == .success)
}
}
}
}
struct AsyncFallibleButton_Previews: PreviewProvider {
static var previews: some View {
AsyncFallibleButton(action: {
print("Start")
for index in 0...5000000 {
_ = index + index
}
throw NSError(domain: "TestError", code: -1)
//print("Finish")
}) { execute in
Text("Hello World")
}
}
}

View File

@@ -0,0 +1,457 @@
//
// FileExplorer.swift
// SideStore
//
// Created by naturecodevoid on 2/16/23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
import ZIPFoundation
import UniformTypeIdentifiers
import minimuxer
extension Binding<URL?>: Equatable {
public static func == (lhs: Binding<URL?>, rhs: Binding<URL?>) -> Bool {
return lhs.wrappedValue == rhs.wrappedValue
}
}
private protocol FileExplorerBackend {
func delete(_ path: URL) throws
func zip(_ path: URL) throws
func insert(file: URL, to: URL) throws
func iterate(_ directory: URL) -> DirectoryEntry
func getQuickLookURL(_ path: URL) throws -> URL
}
private class NormalFileExplorerBackend: FileExplorerBackend {
func delete(_ path: URL) throws {
try FileManager.default.removeItem(at: path)
}
func zip(_ path: URL) throws {
let dest = FileManager.default.documentsDirectory.appendingPathComponent(path.pathComponents.last! + ".zip")
do {
try FileManager.default.removeItem(at: dest)
} catch {}
try FileManager.default.zipItem(at: path, to: dest)
}
func insert(file: URL, to: URL) throws {
try FileManager.default.copyItem(at: file, to: to.appendingPathComponent(file.pathComponents.last!), shouldReplace: true)
}
private func _iterate(directory: URL, parent: URL) -> DirectoryEntry {
var directoryEntry = DirectoryEntry(path: directory, parent: parent, isFile: false)
if let contents = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: []) {
for entry in contents {
if entry.hasDirectoryPath {
directoryEntry.children!.append(_iterate(directory: entry, parent: directory))
} else {
directoryEntry.children!.append(DirectoryEntry(path: entry, parent: directory, isFile: true, size: {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: entry.description.replacingOccurrences(of: "file://", with: "")) else { return nil }
return attributes[FileAttributeKey.size] as? Double
}()))
}
}
}
return directoryEntry
}
func iterate(_ directory: URL) -> DirectoryEntry {
return _iterate(directory: directory, parent: directory)
}
func getQuickLookURL(_ path: URL) throws -> URL {
path
}
}
private class AfcFileExplorerBackend: FileExplorerBackend {
func delete(_ path: URL) throws {
try AfcFileManager.remove(path.description.replacingOccurrences(of: "file://", with: "").removingPercentEncoding!)
}
func zip(_ path: URL) throws {
throw NSError(domain: "AFC currently doesn't support zipping a directory/file. however, it is possible (we should be able to copy the files outside of AFC and then zip the copied directory/file), it just hasn't been implemented", code: -1)
}
func insert(file: URL, to: URL) throws {
let data = try Data(contentsOf: file)
let rustByteSlice = data.toRustByteSlice()
let to = to.appendingPathComponent(file.lastPathComponent).description.replacingOccurrences(of: "file://", with: "").removingPercentEncoding!
print("writing to \(to)")
try AfcFileManager.writeFile(to, rustByteSlice.forRust())
}
private func _addChildren(_ rustEntry: RustDirectoryEntryRef) -> DirectoryEntry {
var entry = DirectoryEntry(
path: URL(string: rustEntry.path().toString())!,
parent: URL(string: rustEntry.parent().toString())!,
isFile: rustEntry.isFile(),
size: rustEntry.size() != nil ? Double(rustEntry.size()!) : nil
)
for child in rustEntry.children() {
entry.children!.append(_addChildren(child))
}
return entry
}
func iterate(_ directory: URL) -> DirectoryEntry {
var directoryEntry = DirectoryEntry(path: directory, parent: directory, isFile: false)
for child in AfcFileManager.contents() {
directoryEntry.children!.append(_addChildren(child))
}
return directoryEntry
}
func getQuickLookURL(_ path: URL) throws -> URL {
throw NSError(domain: "AFC currently doesn't support viewing a file. however, it is possible (we should be able to copy the file outside of AFC and then view the copied file), it just hasn't been implemented", code: -1)
}
}
private struct DirectoryEntry: Identifiable {
var id = UUID()
var path: URL
var parent: URL
var isFile: Bool
var size: Double?
var children: [DirectoryEntry]? = []
var asString: String {
let str = path.description.replacingOccurrences(of: parent.description, with: "").removingPercentEncoding!
if str.count <= 0 {
return "/"
}
return str
}
}
private enum FileExplorerAction {
case delete
case zip
case insert
case quickLook
}
private struct File: View {
@ObservedObject private var iO = Inject.observer
var item: DirectoryEntry
var backend: FileExplorerBackend
@Binding var explorerHidden: Bool
@State var quickLookURL: URL?
@State var fileExplorerAction: FileExplorerAction?
@State var hidden = false
@State var isShowingFilePicker = false
@State var selectedFile: URL?
var body: some View {
AsyncFallibleButton(action: {
switch (fileExplorerAction) {
case .delete:
print("deleting \(item.path.description)")
try backend.delete(item.path)
case .zip:
print("zipping \(item.path.description)")
try backend.zip(item.path)
case .insert:
print("inserting \(selectedFile!.description) to \(item.path.description)")
try backend.insert(file: selectedFile!, to: item.path)
explorerHidden = true
explorerHidden = false
case .quickLook:
print("viewing \(item.path.description)")
quickLookURL = try backend.getQuickLookURL(item.path)
default:
print("unknown action for \(item.path.description): \(String(describing: fileExplorerAction))")
}
}, label: { execute in
HStack {
Text(item.asString)
if item.isFile {
Text(getFileSize(item.size)).foregroundColor(.secondary)
}
Spacer()
Menu {
if item.isFile {
SwiftUI.Button(action: {
fileExplorerAction = .quickLook
execute()
}) {
Label("View/Share", systemSymbol: .eye)
}
} else {
SwiftUI.Button(action: {
fileExplorerAction = .zip
execute()
}) {
Label("Save to ZIP file", systemSymbol: .squareAndArrowDown)
}
SwiftUI.Button {
isShowingFilePicker = true
} label: {
Label("Insert file", systemSymbol: .plus)
}
}
if item.asString != "/" {
SwiftUI.Button(action: {
fileExplorerAction = .delete
execute()
}) {
Label("Delete", systemSymbol: .trash)
}
}
} label: {
Image(systemSymbol: .ellipsis)
.frame(width: 20, height: 20) // Make it easier to tap
}
}
.onChange(of: $selectedFile) { file in
guard file.wrappedValue != nil else { return }
fileExplorerAction = .insert
execute()
}
}, afterFinish: { success in
switch (fileExplorerAction) {
case .delete:
if success { hidden = true }
case .zip:
UIApplication.shared.open(URL(string: "shareddocuments://" + FileManager.default.documentsDirectory.description.replacingOccurrences(of: "file://", with: ""))!, options: [:], completionHandler: nil)
default: break
}
}, wrapInButton: false)
.quickLookPreview($quickLookURL)
.sheet(isPresented: $isShowingFilePicker) {
DocumentPicker(selectedUrl: $selectedFile, supportedTypes: allUTITypes().map({ $0.identifier }))
.ignoresSafeArea()
}
.isHidden($hidden)
.enableInjection()
}
func getFileSize(_ bytes: Double?) -> String {
guard var bytes = bytes else { return "Unknown file size" }
// https://stackoverflow.com/a/14919494 (ported to swift)
let thresh = 1024.0;
if (bytes < thresh) {
return String(describing: bytes) + " B";
}
let units = ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
var u = -1;
while (bytes >= thresh && u < units.count - 1) {
bytes /= thresh;
u += 1;
}
return String(format: "%.2f", bytes) + " " + units[u];
}
}
struct FileExplorer: View {
@ObservedObject private var iO = Inject.observer
private var url: URL?
private var backend: FileExplorerBackend
private init(_ url: URL?, _ backend: FileExplorerBackend) {
self.url = url
self.backend = backend
}
static func normal(url: URL?) -> FileExplorer {
FileExplorer(url, NormalFileExplorerBackend())
}
static func afc() -> FileExplorer {
FileExplorer(URL(string: "/")!, AfcFileExplorerBackend())
}
@State var hidden = false
var body: some View {
List([backend.iterate(url!)], children: \.children) { item in
File(item: item, backend: backend, explorerHidden: $hidden)
}
.toolbar {
ToolbarItem {
SwiftUI.Button {
hidden = true
hidden = false
} label: {
Image(systemSymbol: .arrowClockwise)
}
}
}
.isHidden($hidden)
.enableInjection()
}
}
struct FileExplorer_Previews: PreviewProvider {
static var previews: some View {
FileExplorer.normal(url: FileManager.default.altstoreSharedDirectory)
}
}
// https://stackoverflow.com/a/72165424
func allUTITypes() -> [UTType] {
let types: [UTType] =
[.item,
.content,
.compositeContent,
.diskImage,
.data,
.directory,
.resolvable,
.symbolicLink,
.executable,
.mountPoint,
.aliasFile,
.urlBookmarkData,
.url,
.fileURL,
.text,
.plainText,
.utf8PlainText,
.utf16ExternalPlainText,
.utf16PlainText,
.delimitedText,
.commaSeparatedText,
.tabSeparatedText,
.utf8TabSeparatedText,
.rtf,
.html,
.xml,
.yaml,
.sourceCode,
.assemblyLanguageSource,
.cSource,
.objectiveCSource,
.swiftSource,
.cPlusPlusSource,
.objectiveCPlusPlusSource,
.cHeader,
.cPlusPlusHeader]
let types_1: [UTType] =
[.script,
.appleScript,
.osaScript,
.osaScriptBundle,
.javaScript,
.shellScript,
.perlScript,
.pythonScript,
.rubyScript,
.phpScript,
.json,
.propertyList,
.xmlPropertyList,
.binaryPropertyList,
.pdf,
.rtfd,
.flatRTFD,
.webArchive,
.image,
.jpeg,
.tiff,
.gif,
.png,
.icns,
.bmp,
.ico,
.rawImage,
.svg,
.livePhoto,
.heif,
.heic,
.webP,
.threeDContent,
.usd,
.usdz,
.realityFile,
.sceneKitScene,
.arReferenceObject,
.audiovisualContent]
let types_2: [UTType] =
[.movie,
.video,
.audio,
.quickTimeMovie,
UTType("com.apple.quicktime-image"),
.mpeg,
.mpeg2Video,
.mpeg2TransportStream,
.mp3,
.mpeg4Movie,
.mpeg4Audio,
.appleProtectedMPEG4Audio,
.appleProtectedMPEG4Video,
.avi,
.aiff,
.wav,
.midi,
.playlist,
.m3uPlaylist,
.folder,
.volume,
.package,
.bundle,
.pluginBundle,
.spotlightImporter,
.quickLookGenerator,
.xpcService,
.framework,
.application,
.applicationBundle,
.applicationExtension,
.unixExecutable,
.exe,
.systemPreferencesPane,
.archive,
.gzip,
.bz2,
.zip,
.appleArchive,
.spreadsheet,
.presentation,
.database,
.message,
.contact,
.vCard,
.toDoItem,
.calendarEvent,
.emailMessage,
.internetLocation,
.internetShortcut,
.font,
.bookmark,
.pkcs12,
.x509Certificate,
.epub,
.log]
.compactMap({ $0 })
return types + types_1 + types_2
}

View File

@@ -0,0 +1,42 @@
//
// HintView.swift
// SideStore
//
// Created by Fabian Thies on 15.01.23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
struct HintView<Content: View>: View {
var backgroundColor: Color = Color(.tertiarySystemBackground)
@ViewBuilder
let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 8) {
self.content()
}
.padding()
.background(self.backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
struct HintView_Previews: PreviewProvider {
static var previews: some View {
ZStack {
Color(.secondarySystemBackground).edgesIgnoringSafeArea(.all)
HintView {
Text("Hint Title")
.bold()
Text("This hint view can be used to tell the user something about how SideStore works.")
.foregroundColor(.secondary)
}
}
}
}

View File

@@ -0,0 +1,45 @@
//
// ModalNavigationLink.swift
// SideStore
//
// Created by Fabian Thies on 03.02.23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
struct ModalNavigationLink<Label: View, Modal: View>: View {
let modal: () -> Modal
let label: () -> Label
@State var isPresentingModal: Bool = false
init(@ViewBuilder modal: @escaping () -> Modal, @ViewBuilder label: @escaping () -> Label) {
self.modal = modal
self.label = label
}
init(_ title: String, @ViewBuilder modal: @escaping () -> Modal) where Label == Text {
self.modal = modal
self.label = { Text(title) }
}
var body: some View {
SwiftUI.Button {
self.isPresentingModal = true
} label: {
self.label()
}
.sheet(isPresented: self.$isPresentingModal) {
self.modal()
}
}
}
struct ModalNavigationLink_Previews: PreviewProvider {
static var previews: some View {
ModalNavigationLink("Present Modal") {
Text("Modal")
}
}
}

View File

@@ -0,0 +1,47 @@
//
// ObservableScrollView.swift
// SideStore
//
// Created by Fabian Thies on 20.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
struct ObservableScrollView<Content: View>: View {
@Namespace var scrollViewNamespace
@Binding var scrollOffset: CGFloat
let content: (ScrollViewProxy) -> Content
init(scrollOffset: Binding<CGFloat>, @ViewBuilder content: @escaping (ScrollViewProxy) -> Content) {
self._scrollOffset = scrollOffset
self.content = content
}
var body: some View {
ScrollView {
ScrollViewReader { proxy in
content(proxy)
.background(GeometryReader { geoReader in
let offset = -geoReader.frame(in: .named(scrollViewNamespace)).minY
Color.clear
.preference(key: ScrollViewOffsetPreferenceKey.self, value: offset)
})
}
}
.coordinateSpace(name: scrollViewNamespace)
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
scrollOffset = value
}
}
}
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat.zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}

View File

@@ -0,0 +1,31 @@
//
// RatingStars.swift
// SideStore
//
// Created by Fabian Thies on 18.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import SFSafeSymbols
struct RatingStars: View {
let rating: Int
var body: some View {
HStack(spacing: 0) {
ForEach(0..<5) { i in
Image(systemSymbol: i < rating ? .starFill : .star)
.resizable()
.aspectRatio(contentMode: .fit)
}
}
}
}
struct RatingStars_Previews: PreviewProvider {
static var previews: some View {
RatingStars(rating: 4)
}
}

View File

@@ -0,0 +1,52 @@
//
// RoundedTextField.swift
// SideStore
//
// Created by Fabian Thies on 29.11.22.
// Copyright © 2022 SideStore. All rights reserved.
//
import SwiftUI
struct RoundedTextField: View {
let title: String?
let placeholder: String
@Binding var text: String
let isSecure: Bool
init(title: String?, placeholder: String, text: Binding<String>, isSecure: Bool = false) {
self.title = title
self.placeholder = placeholder
self._text = text
self.isSecure = isSecure
}
init(_ placeholder: String, text: Binding<String>, isSecure: Bool = false) {
self.init(title: nil, placeholder: placeholder, text: text, isSecure: isSecure)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let title {
Text(title.uppercased())
.font(.system(size: 12))
.foregroundColor(.secondary)
.padding(.horizontal)
}
HStack(alignment: .center) {
if isSecure {
SecureField(placeholder, text: $text)
} else {
TextField(placeholder, text: $text)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.foregroundColor(Color(.secondarySystemBackground))
)
}
}
}