From 079bd57de889e5102efa6576f06ac6aa3dfb6da3 Mon Sep 17 00:00:00 2001 From: Amir Date: Tue, 27 Aug 2024 07:44:09 +0330 Subject: [PATCH] Feat: SourceFolders (#1592) * Initial Creation * Added Search Back + Bug & UI Fixes * Sort Alphabetically Toggle Option * Fix Sort Issues + Save to Defaults + `.help` * Backport Compatibility * Add `Semaphore` to `README.md` * SwiftLint * Various Fixes & Improvements - Search Field Fix - Local IPA Source Fix - Name Local Legacy IPA Sources `localhost` - Bug where if a source load fails could't load other ones fix - Added Source List Search * SwiftLint * Apply recent UI change * iTunes Playlist Style Sidebar for Sources * Collapsible Source Folders list is Sidebar * Making Collapse Button Smaller * Reviews * Update project.pbxproj * some cleanup and bug fixes * fix the folders duplicate bug * rebase with develop branch * dumbest fix ever * Update and Fixes StoreVM SourceResolve Improvement Switch to NavigationStack package (not mine, should be updated after my PR gets merged) * fix unwanted comment * remove redundant strings * remove searchable from sources list * Last Commit (Hopefully) * `try! beLastCommit` * Icon Unifications + Fix `var` Warn * Fix + Improvements 1. Makes sure Sidebar width shows IPALibrary completely 2. Marks `appendSourceData()` as a private function --- Cartfile.resolved | 2 +- PlayCover.xcodeproj/project.pbxproj | 4 + PlayCover/AppInstaller/Downloader.swift | 4 +- PlayCover/ViewModel/DownloadVM.swift | 2 +- PlayCover/ViewModel/StoreAppVM.swift | 4 +- PlayCover/ViewModel/StoreVM.swift | 269 +++++++++--------- PlayCover/Views/App Views/StoreAppView.swift | 8 +- PlayCover/Views/MainView.swift | 53 +++- .../Views/Settings/IPASourceSettings.swift | 104 ++++--- .../Views/Sidebar Views/AppLibraryView.swift | 30 +- .../Views/Sidebar Views/IPALibraryView.swift | 197 +++++++------ .../Views/Sidebar Views/IPASourceView.swift | 145 ++++++++++ PlayCover/Views/StoreInfoAppView.swift | 2 +- PlayCover/en.lproj/Localizable.strings | 1 + 14 files changed, 518 insertions(+), 307 deletions(-) create mode 100644 PlayCover/Views/Sidebar Views/IPASourceView.swift diff --git a/Cartfile.resolved b/Cartfile.resolved index 1eaee7b53..80e2a6b35 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1 +1 @@ -github "PlayCover/PlayTools" "v3.0.0" +github "PlayCover/PlayTools" "v3.0.0" \ No newline at end of file diff --git a/PlayCover.xcodeproj/project.pbxproj b/PlayCover.xcodeproj/project.pbxproj index f7032a6f3..bd0239231 100644 --- a/PlayCover.xcodeproj/project.pbxproj +++ b/PlayCover.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 53F3802826EB6F6B00D6B525 /* NotifyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F3802726EB6F6B00D6B525 /* NotifyService.swift */; }; 53F4D2A026C43C690020167C /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F4D29F26C43C690020167C /* Log.swift */; }; 53F50C4926E3CA42007AD2D3 /* AppLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F50C4826E3CA42007AD2D3 /* AppLibraryView.swift */; }; + 6888981729F9158700105D9C /* IPASourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6888981629F9158700105D9C /* IPASourceView.swift */; }; 68E48B952957046D00C39879 /* DownloadManager in Frameworks */ = {isa = PBXBuildFile; productRef = 68E48B942957046D00C39879 /* DownloadManager */; }; 68E48B97295704A600C39879 /* Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68E48B96295704A600C39879 /* Downloader.swift */; }; 68E48B9A295708C800C39879 /* Injection in Frameworks */ = {isa = PBXBuildFile; productRef = 68E48B99295708C800C39879 /* Injection */; }; @@ -111,6 +112,7 @@ 53F4D29F26C43C690020167C /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; 53F50C4826E3CA42007AD2D3 /* AppLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLibraryView.swift; sourceTree = ""; }; 6854C5E528D53C9500CE28A0 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/Localizable.strings; sourceTree = ""; }; + 6888981629F9158700105D9C /* IPASourceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IPASourceView.swift; sourceTree = ""; }; 68C79E67296741580041DBC9 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/Localizable.strings; sourceTree = ""; }; 68E48B96295704A600C39879 /* Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Downloader.swift; path = PlayCover/AppInstaller/Downloader.swift; sourceTree = SOURCE_ROOT; }; 68E48B9B295709BF00C39879 /* Cacher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Cacher.swift; sourceTree = ""; }; @@ -360,6 +362,7 @@ children = ( 53F50C4826E3CA42007AD2D3 /* AppLibraryView.swift */, 6E66B0C8289F52800099B907 /* IPALibraryView.swift */, + 6888981629F9158700105D9C /* IPASourceView.swift */, ); path = "Sidebar Views"; sourceTree = ""; @@ -675,6 +678,7 @@ B67C1A112AE8091F00F396CC /* StoreInfoAppView.swift in Sources */, B17FD04728C7B0D900B1D4CA /* AssetsExtractor.swift in Sources */, ABDAD80629893CF900DC164F /* KeyCoverSetupViews.swift in Sources */, + 6888981729F9158700105D9C /* IPASourceView.swift in Sources */, 6EE8265028E8AB2B003935BC /* DownloadVM.swift in Sources */, AB00EB5229A7BF17006F3225 /* KeyCover.swift in Sources */, 68E48B9C295709C000C39879 /* Cacher.swift in Sources */, diff --git a/PlayCover/AppInstaller/Downloader.swift b/PlayCover/AppInstaller/Downloader.swift index e61d8f44f..223e3144e 100644 --- a/PlayCover/AppInstaller/Downloader.swift +++ b/PlayCover/AppInstaller/Downloader.swift @@ -22,10 +22,10 @@ import DownloadManager class DownloadApp { let url: URL? - let app: StoreAppData? + let app: SourceAppsData? let warning: String? - init(url: URL?, app: StoreAppData?, warning: String?) { + init(url: URL?, app: SourceAppsData?, warning: String?) { self.url = url self.app = app self.warning = warning diff --git a/PlayCover/ViewModel/DownloadVM.swift b/PlayCover/ViewModel/DownloadVM.swift index 800ea789c..d200baee0 100644 --- a/PlayCover/ViewModel/DownloadVM.swift +++ b/PlayCover/ViewModel/DownloadVM.swift @@ -16,7 +16,7 @@ enum DownloadStepsNative: String { } class DownloadVM: ProgressVM { - @Published var storeAppData: StoreAppData? + @Published var storeAppData: SourceAppsData? static let shared = DownloadVM() diff --git a/PlayCover/ViewModel/StoreAppVM.swift b/PlayCover/ViewModel/StoreAppVM.swift index 414ed50f0..990b85c5e 100644 --- a/PlayCover/ViewModel/StoreAppVM.swift +++ b/PlayCover/ViewModel/StoreAppVM.swift @@ -8,9 +8,9 @@ import Foundation class StoreAppVM: ObservableObject { - @Published var data: StoreAppData + @Published var data: SourceAppsData - init(data: StoreAppData) { + init(data: SourceAppsData) { self.data = data } } diff --git a/PlayCover/ViewModel/StoreVM.swift b/PlayCover/ViewModel/StoreVM.swift index f6b8e7de6..7a876f152 100644 --- a/PlayCover/ViewModel/StoreVM.swift +++ b/PlayCover/ViewModel/StoreVM.swift @@ -8,182 +8,193 @@ import Foundation class StoreVM: ObservableObject, @unchecked Sendable { - - static let shared = StoreVM() + public static let shared = StoreVM() + private let plistSource: URL private init() { - sourcesUrl = PlayTools.playCoverContainer + plistSource = PlayTools.playCoverContainer .appendingPathComponent("Sources") .appendingPathExtension("plist") - sources = [] - if !decode() { - encode() - } + sourcesList = [] + if !decode() { encode() } resolveSources() } - @Published var apps: [StoreAppData] = [] - @Published var searchText: String = "" - @Published var filteredApps: [StoreAppData] = [] - @Published var sources: [SourceData] { + @Published var sourcesList: [SourceData] { didSet { encode() } } - - let sourcesUrl: URL - - @discardableResult - public func decode() -> Bool { - do { - let data = try Data(contentsOf: sourcesUrl) - sources = try PropertyListDecoder().decode([SourceData].self, from: data) - return true - } catch { - print(error) - return false + @Published var sourcesData: [SourceJSON] = [] { + didSet { + sourcesApps.removeAll() + for source in sourcesData { + appendSourceData(source) + } } } + @Published var sourcesApps: [SourceAppsData] = [] - @discardableResult - public func encode() -> Bool { - let encoder = PropertyListEncoder() - encoder.outputFormat = .xml + private var resolveTask: Task? - do { - let data = try encoder.encode(sources) - try data.write(to: sourcesUrl) - return true - } catch { - print(error) - return false + // + func addSource(_ source: SourceData) { + sourcesList.append(source) + resolveSources() + } + + // + func deleteSource(_ selectedSource: inout Set) { + sourcesList.removeAll { + selectedSource.contains($0.id) } + resolveSources() } - func appendAppData(_ data: [StoreAppData]) { - for element in data { - if let index = apps.firstIndex(where: {$0.bundleID == element.bundleID}) { - if apps[index].version < element.version { - apps[index] = element - continue + // + func moveSourceUp(_ selectedSource: inout Set) { + let selected = sourcesList.filter { + selectedSource.contains($0.id) + } + if let first = sourcesList.first, + let data = selected.first { + if data != first { + if var index = sourcesList.firstIndex(of: data) { + index -= 1 + sourcesList.removeAll { + selectedSource.contains($0.id) + } + sourcesList.insert(contentsOf: selected, at: index) } - } else { - apps.append(element) + resolveSources() } } - fetchApps() } - func fetchApps() { - filteredApps.removeAll() - filteredApps = apps - if !searchText.isEmpty { - filteredApps = filteredApps.filter({ - $0.name.lowercased().contains(searchText.lowercased()) - }) + // + func moveSourceDown(_ selectedSource: inout Set) { + let selected = sourcesList.filter { + selectedSource.contains($0.id) + } + + if let last = sourcesList.last, + let data = selected.first { + if data != last { + if var index = sourcesList.firstIndex(of: data) { + index += 1 + sourcesList.removeAll { + selectedSource.contains($0.id) + } + sourcesList.insert(contentsOf: selected, at: index) + } + resolveSources() + } } } + // func resolveSources() { - guard NetworkVM.isConnectedToNetwork() else { - return - } + resolveTask?.cancel() + resolveTask = Task { @MainActor in - apps.removeAll() - for index in 0.. 0 { - Task { @MainActor in - self.sources[index].status = self.sources[0..) { - self.sources.removeAll(where: { selected.contains($0.id) }) - selected.removeAll() - resolveSources() + } } - func moveSourceUp(_ selected: inout Set) { - let selectedData = self.sources.filter({ selected.contains($0.id) }) + // + @discardableResult private func encode() -> Bool { + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml - if let first = selectedData.first { - if var index = self.sources.firstIndex(of: first) { - index -= 1 - self.sources.removeAll(where: { selected.contains($0.id) }) - if index < 0 { - index = 0 - } - self.sources.insert(contentsOf: selectedData, at: index) - } + do { + let data = try encoder.encode(sourcesList) + try data.write(to: plistSource) + return true + } catch { + print("StoreVM: Failed to encode Sources.plist! ", error) + return false } } - func moveSourceDown(_ selected: inout Set) { - let selectedData = self.sources.filter({ selected.contains($0.id) }) + // + @discardableResult private func decode() -> Bool { + do { + let data = try Data(contentsOf: plistSource) + sourcesList = try PropertyListDecoder().decode([SourceData].self, from: data) + return true + } catch { + print("StoreVM: Failed to decode Sources.plist! ", error) + return false + } + } - if let first = selectedData.first { - if var index = self.sources.firstIndex(of: first) { - index += 1 - self.sources.removeAll(where: { selected.contains($0.id) }) - if index > self.sources.endIndex { - index = self.sources.endIndex - } - self.sources.insert(contentsOf: selectedData, at: index) + // + private func getSourceData(sourceLink: String) async -> (SourceJSON?, SourceValidation) { + guard let url = URL(string: sourceLink) else { return (nil, .badurl) } + var dataToDecode: Data? + do { + let (data, response) = try await URLSession.shared.data( + for: URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData) + ) + if !url.isFileURL { + guard (response as? HTTPURLResponse)?.statusCode == 200 else { return (nil, .badurl) } + } + dataToDecode = data + } catch { + debugPrint("Error decoding data from URL: \(url): \(error)") + return (nil, .badjson) + } + guard let unwrappedData = dataToDecode else { return (nil, .badurl) } + var decodedData: SourceJSON? + do { + decodedData = try JSONDecoder().decode(SourceJSON.self, from: unwrappedData) + return (decodedData, .valid) + } catch { + do { + let sourceName = url.isFileURL + ? (url.absoluteString as NSString).lastPathComponent.replacingOccurrences(of: ".json", with: "") + : url.host ?? url.absoluteString + let oldTypeJson: [SourceAppsData] = try JSONDecoder().decode([SourceAppsData].self, from: unwrappedData) + decodedData = SourceJSON(name: sourceName, data: oldTypeJson) + return (decodedData, .valid) + } catch { + debugPrint("Error decoding data from URL: \(url): \(error)") + return (nil, .badjson) } } } - func appendSourceData(_ data: SourceData) { - self.sources.append(data) - self.resolveSources() + // + private func appendSourceData(_ source: SourceJSON) { + for app in source.data where !sourcesApps.contains(app) { + sourcesApps.append(app) + } } + +} + +// Source Data Structure +struct SourceJSON: Codable, Equatable, Hashable { + let name: String + let data: [SourceAppsData] } -struct StoreAppData: Codable, Equatable { - var bundleID: String +struct SourceAppsData: Codable, Equatable, Hashable { + let bundleID: String let name: String let version: String let itunesLookup: String diff --git a/PlayCover/Views/App Views/StoreAppView.swift b/PlayCover/Views/App Views/StoreAppView.swift index 4e078cf68..44b5c03b3 100644 --- a/PlayCover/Views/App Views/StoreAppView.swift +++ b/PlayCover/Views/App Views/StoreAppView.swift @@ -12,9 +12,9 @@ import CachedAsyncImage struct StoreAppView: View { @Binding var selectedBackgroundColor: Color @Binding var selectedTextColor: Color - @Binding var selected: StoreAppData? + @Binding var selected: SourceAppsData? - @State var app: StoreAppData + @State var app: SourceAppsData @State var isList: Bool @State var observation: NSKeyValueObservation? @State var showInfo = false @@ -83,9 +83,9 @@ struct StoreAppView: View { struct StoreAppConditionalView: View { @Binding var selectedBackgroundColor: Color @Binding var selectedTextColor: Color - @Binding var selected: StoreAppData? + @Binding var selected: SourceAppsData? - @State var app: StoreAppData + @State var app: SourceAppsData @State var itunesResponse: ITunesResponse? @State var onlineIcon: URL? @State var localIcon: NSImage? diff --git a/PlayCover/Views/MainView.swift b/PlayCover/Views/MainView.swift index 62b38f08e..4afa7f227 100644 --- a/PlayCover/Views/MainView.swift +++ b/PlayCover/Views/MainView.swift @@ -22,6 +22,7 @@ struct MainView: View { @State private var navWidth: CGFloat = 0 @State private var viewWidth: CGFloat = 0 @State private var collapsed: Bool = false + @State private var showSourceFolders = true @State private var selectedBackgroundColor: Color = Color.accentColor @State private var selectedTextColor: Color = Color.black @@ -32,24 +33,56 @@ struct MainView: View { NavigationView { GeometryReader { sidebarGeom in List { - NavigationLink(destination: AppLibraryView(selectedBackgroundColor: $selectedBackgroundColor, - selectedTextColor: $selectedTextColor), - tag: 1, selection: self.$selectedView) { + NavigationLink(tag: 1, selection: $selectedView) { + AppLibraryView(selectedBackgroundColor: $selectedBackgroundColor, + selectedTextColor: $selectedTextColor) + } label: { Label("sidebar.appLibrary", systemImage: "square.grid.2x2") } - NavigationLink(destination: IPALibraryView(selectedBackgroundColor: $selectedBackgroundColor, - selectedTextColor: $selectedTextColor) + NavigationLink(tag: 2, selection: $selectedView) { + IPALibraryView(storeVM: store, + selectedBackgroundColor: $selectedBackgroundColor, + selectedTextColor: $selectedTextColor) .environmentObject(store) - .environmentObject(DownloadVM.shared), - tag: 2, selection: self.$selectedView) { - Label("sidebar.ipaLibrary", systemImage: "arrow.down.circle") + } label: { + HStack { + Label("sidebar.ipaLibrary", systemImage: "arrow.down.circle") + Button { + withAnimation { + showSourceFolders.toggle() + } + } label: { + Image(systemName: showSourceFolders ? "chevron.up" : "chevron.down") + .font(.caption) + } + .buttonStyle(.plain) + } + } + if showSourceFolders { + ForEach(store.sourcesData, id: \.hashValue) { source in + NavigationLink { + IPASourceView(storeVM: store, + selectedBackgroundColor: $selectedBackgroundColor, + selectedTextColor: $selectedTextColor, + sourceName: source.name, + sourceApps: source.data) + .environmentObject(store) + } label: { + Label(source.name, systemImage: "folder") + .font(.caption) + .padding(.leading) + } + } } } + .frame(minWidth: 150) .toolbar { ToolbarItem { // Sits on the left by default - Button(action: toggleSidebar, label: { + Button { + toggleSidebar() + } label: { Image(systemName: "sidebar.leading") - }) + } } } .onChange(of: sidebarGeom.size) { newSize in diff --git a/PlayCover/Views/Settings/IPASourceSettings.swift b/PlayCover/Views/Settings/IPASourceSettings.swift index 1f29ef08e..c4948be89 100644 --- a/PlayCover/Views/Settings/IPASourceSettings.swift +++ b/PlayCover/Views/Settings/IPASourceSettings.swift @@ -41,59 +41,55 @@ struct IPASourceSettings: View { var body: some View { Form { HStack { - List(storeVM.sources, id: \.id, selection: $selected) { source in + List(storeVM.sourcesList, id: \.id, selection: $selected) { source in SourceView(source: source) } .listStyle(.bordered(alternatesRowBackgrounds: true)) Spacer() .frame(width: 20) VStack { - Button(action: { - addSource() - }, label: { + Button { + addSourceSheet.toggle() + } label: { Text("preferences.button.addSource") .frame(width: 130) - }) - Button(action: { + } + Button { storeVM.deleteSource(&selected) - }, label: { + } label: { Text("preferences.button.deleteSource") .frame(width: 130) - }) + } .disabled(!selectedNotEmpty) Spacer() .frame(height: 20) - Button(action: { + Button { storeVM.moveSourceUp(&selected) - }, label: { + } label: { Text("preferences.button.moveSourceUp") .frame(width: 130) - }) + } .disabled(!selectedNotEmpty) - Button(action: { + Button { storeVM.moveSourceDown(&selected) - }, label: { + } label: { Text("preferences.button.moveSourceDown") .frame(width: 130) - }) + } .disabled(!selectedNotEmpty) Spacer() .frame(height: 20) - Button(action: { + Button { storeVM.resolveSources() - }, label: { + } label: { Text("playapp.refreshSources") .frame(width: 130) - }) + } } } } - .onChange(of: selected) { _ in - if selected.count > 0 { - selectedNotEmpty = true - } else { - selectedNotEmpty = false - } + .onChange(of: selected) { data in + selectedNotEmpty = data.count > 0 } .padding(20) .frame(width: 600, height: 300, alignment: .center) @@ -102,10 +98,6 @@ struct IPASourceSettings: View { .environmentObject(storeVM) } } - - func addSource() { - addSourceSheet.toggle() - } } struct SourceView: View { @@ -156,12 +148,12 @@ struct StatusBadgeView: View { @Binding var showingPopover: Bool var body: some View { - Button(action: { + Button { showingPopover.toggle() - }, label: { + } label: { Image(systemName: imageName) .foregroundColor(imageColor) - }) + } .buttonStyle(.plain) .popover(isPresented: $showingPopover) { Text(NSLocalizedString(popoverText, comment: "")) @@ -220,19 +212,19 @@ struct AddSourceView: View { .font(.system(.subheadline)) } Spacer() - Button(action: { + Button { addSourceSheet.toggle() - }, label: { + } label: { Text("button.Cancel") - }) - Button(action: { - if let sourceURL = newSourceURL { - storeVM.appendSourceData(SourceData(source: sourceURL.absoluteString)) + } + Button { + if let sourceURL = newSourceURL?.absoluteString { + storeVM.addSource(SourceData(source: sourceURL)) addSourceSheet.toggle() } - }, label: { + } label: { Text("button.OK") - }) + } .tint(.accentColor) .keyboardShortcut(.defaultAction) .disabled(![.valid, .duplicate].contains(sourceValidationState)) @@ -263,9 +255,7 @@ struct AddSourceView: View { } func validateSource(_ source: String) { - guard NetworkVM.isConnectedToNetwork() else { - return - } + guard NetworkVM.isConnectedToNetwork() else { return } sourceValidationState = .empty @@ -276,7 +266,6 @@ struct AddSourceView: View { } newSourceURL = url - urlSessionTask = URLSession.shared.dataTask(with: URLRequest(url: url)) { jsonData, response, error in guard error == nil, ((response as? HTTPURLResponse)?.statusCode ?? 200) == 200, @@ -288,29 +277,34 @@ struct AddSourceView: View { } do { - let data: [StoreAppData] = try JSONDecoder().decode([StoreAppData].self, - from: jsonData) - if data.count > 0 { - Task { @MainActor in - sourceValidationState = storeVM.sources.filter({ - $0.source == source - }).isEmpty ? .valid : .duplicate - } + let _: SourceJSON = try JSONDecoder().decode(SourceJSON.self, from: jsonData) + Task { @MainActor in + sourceValidationState = storeVM.sourcesList.filter { + $0.source == source + }.isEmpty ? .valid : .duplicate } } catch { - Task { @MainActor in - self.sourceValidationState = .badjson + do { + let data: [SourceAppsData] = try JSONDecoder().decode([SourceAppsData].self, from: jsonData) + if data.count > 0 { + Task { @MainActor in + sourceValidationState = storeVM.sourcesList.filter { + $0.source == source + }.isEmpty ? .valid : .duplicate + } + } + } catch { + Task { @MainActor in + self.sourceValidationState = .badjson + } } } } urlSessionTask?.resume() - sourceValidationState = .checking - return } - Task { @MainActor in self.sourceValidationState = .badurl } diff --git a/PlayCover/Views/Sidebar Views/AppLibraryView.swift b/PlayCover/Views/Sidebar Views/AppLibraryView.swift index 18c00cbbc..f47ff7169 100644 --- a/PlayCover/Views/Sidebar Views/AppLibraryView.swift +++ b/PlayCover/Views/Sidebar Views/AppLibraryView.swift @@ -59,18 +59,7 @@ struct AppLibraryView: View { .navigationTitle("sidebar.appLibrary") .toolbar { ToolbarItem(placement: .primaryAction) { - Button(action: { - showSettings.toggle() - }, label: { - Image(systemName: "gear") - }) - .disabled(selected == nil) - } - ToolbarItem(placement: .primaryAction) { - Spacer() - } - ToolbarItem(placement: .primaryAction) { - Button(action: { + Button { if installVM.inProgress { Log.shared.error(PlayCoverError.waitInstallation) } else if downloadVM.inProgress { @@ -78,10 +67,21 @@ struct AppLibraryView: View { } else { selectFile() } - }, label: { - Image(systemName: "plus") + } label: { + Image(systemName: "plus.circle") .help("playapp.add") - }) + } + } + ToolbarItem(placement: .primaryAction) { + Spacer() + } + ToolbarItem(placement: .primaryAction) { + Button { + showSettings.toggle() + } label: { + Image(systemName: "gear") + } + .disabled(selected == nil) } ToolbarItem(placement: .primaryAction) { Picker("Grid View Layout", selection: $isList) { diff --git a/PlayCover/Views/Sidebar Views/IPALibraryView.swift b/PlayCover/Views/Sidebar Views/IPALibraryView.swift index 928246092..457d3bc02 100644 --- a/PlayCover/Views/Sidebar Views/IPALibraryView.swift +++ b/PlayCover/Views/Sidebar Views/IPALibraryView.swift @@ -6,26 +6,85 @@ // import SwiftUI +import CachedAsyncImage struct IPALibraryView: View { - @EnvironmentObject var storeVM: StoreVM + + @ObservedObject var storeVM: StoreVM + @ObservedObject private var URLObserved = URLObservable.shared @EnvironmentObject var downloadVM: DownloadVM @Binding var selectedBackgroundColor: Color @Binding var selectedTextColor: Color - @State private var gridLayout = [GridItem(.adaptive(minimum: 130, maximum: .infinity))] + @State private var selected: SourceAppsData? + @State private var searchString = "" + @State private var filteredApps: [SourceAppsData] = [] + @State private var isList = UserDefaults.standard.bool(forKey: "IPALibraryView") - @State private var selected: StoreAppData? + @State private var sortAlphabetical = UserDefaults.standard.bool(forKey: "IPASourceAlphabetically") + @State private var addSourcePresented = false - @State private var showInfo = false + @State private var showAppInfo = false - @ObservedObject private var URLObserved = URLObservable.shared + @State private var gridLayout = [GridItem(.adaptive(minimum: 130, maximum: .infinity))] var body: some View { + let sortedApps = storeVM.sourcesApps.sorted(by: { $0.name.lowercased() < $1.name.lowercased() }) Group { - if !NetworkVM.isConnectedToNetwork() { + if NetworkVM.isConnectedToNetwork() { + if storeVM.sourcesList.isEmpty { + VStack { + Spacer() + Text("ipaLibrary.noSources.title") + .font(.title) + .padding(.bottom, 2) + Text("ipaLibrary.noSources.subtitle") + .font(.subheadline) + .foregroundColor(.secondary) + Button("ipaLibrary.noSources.button") { + addSourcePresented.toggle() + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + if !isList { + LazyVGrid(columns: gridLayout, alignment: .center) { + ForEach(searchString.isEmpty + ? sortAlphabetical ? sortedApps : storeVM.sourcesApps + : filteredApps, id: \.bundleID) { app in + StoreAppView(selectedBackgroundColor: $selectedBackgroundColor, + selectedTextColor: $selectedTextColor, + selected: $selected, + app: app, + isList: isList) + } + } + .padding() + Spacer() + } else { + VStack { + ForEach(searchString.isEmpty + ? sortAlphabetical ? sortedApps : storeVM.sourcesApps + : filteredApps, id: \.bundleID) { app in + StoreAppView(selectedBackgroundColor: $selectedBackgroundColor, + selectedTextColor: $selectedTextColor, + selected: $selected, + app: app, + isList: isList) + .environmentObject(DownloadVM.shared) + .environmentObject(InstallVM.shared) + } + Spacer() + } + .padding() + } + } + } + } else { VStack { Text("ipaLibrary.noNetworkConnection.toast") .font(.title) @@ -34,93 +93,50 @@ struct IPALibraryView: View { .font(.subheadline) .foregroundColor(.secondary) Button("button.Reload") { - StoreVM.shared.resolveSources() - } - } - } else if storeVM.sources.count == 0 { - VStack { - Spacer() - Text("ipaLibrary.noSources.title") - .font(.title) - .padding(.bottom, 2) - Text("ipaLibrary.noSources.subtitle") - .font(.subheadline) - .foregroundColor(.secondary) - Button("ipaLibrary.noSources.button", action: { - addSourcePresented.toggle() - }) - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - if !isList { - LazyVGrid(columns: gridLayout, alignment: .center) { - ForEach(storeVM.filteredApps, id: \.bundleID) { app in - StoreAppView(selectedBackgroundColor: $selectedBackgroundColor, - selectedTextColor: $selectedTextColor, - selected: $selected, - app: app, - isList: isList) - .environmentObject(DownloadVM.shared) - .environmentObject(InstallVM.shared) - } - } - .padding() - Spacer() - } else { - VStack { - ForEach(storeVM.filteredApps, id: \.bundleID) { app in - StoreAppView(selectedBackgroundColor: $selectedBackgroundColor, - selectedTextColor: $selectedTextColor, - selected: $selected, - app: app, - isList: isList) - .environmentObject(DownloadVM.shared) - .environmentObject(InstallVM.shared) - } - Spacer() - } - .padding() + storeVM.resolveSources() } } } } + .navigationTitle("sidebar.ipaLibrary") .onTapGesture { selected = nil } - .navigationTitle("sidebar.ipaLibrary") .toolbar { ToolbarItem(placement: .primaryAction) { - Button(action: { - showInfo.toggle() - }, label: { - Image(systemName: "info.circle") - }) - .disabled(selected == nil) + Button { + addSourcePresented.toggle() + } label: { + Image(systemName: "plus.circle") + .help("playapp.addSource") + } + } + ToolbarItem(placement: .primaryAction) { + Button { + storeVM.resolveSources() + } label: { + Image(systemName: "arrow.clockwise.circle") + .help("playapp.refreshSources") + } + .disabled(storeVM.sourcesList.isEmpty) } ToolbarItem(placement: .primaryAction) { Spacer() } ToolbarItem(placement: .primaryAction) { - Button(action: { - storeVM.resolveSources() - }, label: { - Image(systemName: "arrow.clockwise") - .help("playapp.refreshSources") - }) - .disabled(storeVM.sources.count == 0) + Button { + showAppInfo.toggle() + } label: { + Image(systemName: "info.circle") + } + .disabled(selected == nil) } ToolbarItem(placement: .primaryAction) { Spacer() } ToolbarItem(placement: .primaryAction) { - Button(action: { - addSourcePresented.toggle() - }, label: { - Image(systemName: "plus") - .help("playapp.addSource") - }) + Toggle("A", isOn: $sortAlphabetical) + .help("ipaLibrary.AlphabeticalSort") } ToolbarItem(placement: .primaryAction) { Picker("", selection: $isList) { @@ -128,31 +144,38 @@ struct IPALibraryView: View { .tag(false) Image(systemName: "list.bullet") .tag(true) - }.pickerStyle(.segmented) + } + .pickerStyle(.segmented) } } .searchable(text: $searchString, placement: .toolbar) - .onChange(of: searchString) { value in - storeVM.searchText = value - storeVM.fetchApps() - } - .onAppear { - storeVM.searchText = "" - storeVM.fetchApps() - } - .onChange(of: isList, perform: { value in - UserDefaults.standard.set(value, forKey: "IPALibraryView") - }) .sheet(isPresented: $addSourcePresented) { AddSourceView(addSourceSheet: $addSourcePresented) .environmentObject(storeVM) } - .sheet(isPresented: $showInfo) { + .sheet(isPresented: $showAppInfo) { if let selected = selected { StoreInfoAppView(viewModel: StoreAppVM(data: selected)) .environmentObject(downloadVM) } } + .onChange(of: isList) { value in + UserDefaults.standard.set(value, forKey: "IPALibraryView") + } + .onChange(of: sortAlphabetical) { value in + UserDefaults.standard.set(value, forKey: "IPASourceAlphabetically") + } + .onChange(of: searchString) { value in + if sortAlphabetical { + filteredApps = sortedApps.filter { + $0.name.lowercased().contains(value.lowercased()) + } + } else { + filteredApps = storeVM.sourcesApps.filter { + $0.name.lowercased().contains(value.lowercased()) + } + } + } .onChange(of: URLObserved.type) {_ in addSourcePresented = URLObserved.type == .source } diff --git a/PlayCover/Views/Sidebar Views/IPASourceView.swift b/PlayCover/Views/Sidebar Views/IPASourceView.swift new file mode 100644 index 000000000..b83b399b9 --- /dev/null +++ b/PlayCover/Views/Sidebar Views/IPASourceView.swift @@ -0,0 +1,145 @@ +// +// IPASourceView.swift +// PlayCover +// +// Created by Amir Mohammadi on 1/21/1402 AP. +// + +import SwiftUI + +struct IPASourceView: View { + + @ObservedObject var storeVM: StoreVM + @EnvironmentObject var downloadVM: DownloadVM + + @Binding var selectedBackgroundColor: Color + @Binding var selectedTextColor: Color + @State var sourceName: String + @State var sourceApps: [SourceAppsData] + + @State private var isList = UserDefaults.standard.bool(forKey: "IPALibraryView") + @State private var sortAlphabetical = UserDefaults.standard.bool(forKey: "IPASourceAlphabetically") + @State private var selected: SourceAppsData? + @State private var searchString = "" + @State private var filteredApps: [SourceAppsData] = [] + + @State private var addSourcePresented = false + @State private var showAppInfo = false + + @State private var gridLayout = [GridItem(.adaptive(minimum: 130, maximum: .infinity))] + + var body: some View { + let sortedApps = sourceApps.sorted(by: { $0.name.lowercased() < $1.name.lowercased() }) + ScrollView { + if !isList { + LazyVGrid(columns: gridLayout, alignment: .center) { + ForEach(searchString == "" + ? sortAlphabetical ? sortedApps : sourceApps + : filteredApps, id: \.bundleID) { app in + StoreAppView(selectedBackgroundColor: $selectedBackgroundColor, + selectedTextColor: $selectedTextColor, + selected: $selected, + app: app, + isList: isList) + } + } + .padding() + Spacer() + } else { + VStack { + ForEach(searchString.isEmpty + ? sortAlphabetical ? sortedApps : sourceApps + : filteredApps, id: \.bundleID) { app in + StoreAppView(selectedBackgroundColor: $selectedBackgroundColor, + selectedTextColor: $selectedTextColor, + selected: $selected, + app: app, + isList: isList) + .environmentObject(DownloadVM.shared) + .environmentObject(InstallVM.shared) + } + Spacer() + } + .padding() + } + } + .navigationTitle(sourceName) + .onTapGesture { + selected = nil + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + addSourcePresented.toggle() + } label: { + Image(systemName: "plus.circle") + .help("playapp.addSource") + } + } + ToolbarItem(placement: .primaryAction) { + Button { + storeVM.resolveSources() + } label: { + Image(systemName: "arrow.clockwise.circle") + .help("playapp.refreshSources") + } + .disabled(storeVM.sourcesList.isEmpty) + } + ToolbarItem(placement: .primaryAction) { + Spacer() + } + ToolbarItem(placement: .primaryAction) { + Button { + showAppInfo.toggle() + } label: { + Image(systemName: "info.circle") + } + .disabled(selected == nil) + } + ToolbarItem(placement: .primaryAction) { + Spacer() + } + ToolbarItem(placement: .primaryAction) { + Toggle("A", isOn: $sortAlphabetical) + .help("ipaLibrary.AlphabeticalSort") + } + ToolbarItem(placement: .primaryAction) { + Picker("", selection: $isList) { + Image(systemName: "square.grid.2x2") + .tag(false) + Image(systemName: "list.bullet") + .tag(true) + } + .pickerStyle(.segmented) + } + } + .searchable(text: $searchString, placement: .toolbar) + .sheet(isPresented: $addSourcePresented) { + AddSourceView(addSourceSheet: $addSourcePresented) + .environmentObject(storeVM) + } + .sheet(isPresented: $showAppInfo) { + if let selected = selected { + StoreInfoAppView(viewModel: StoreAppVM(data: selected)) + .environmentObject(downloadVM) + } + } + .onChange(of: isList) { value in + UserDefaults.standard.set(value, forKey: "IPALibraryView") + } + .onChange(of: sortAlphabetical) { value in + UserDefaults.standard.set(value, forKey: "IPASourceAlphabetically") + } + .onChange(of: searchString) { value in + if sortAlphabetical { + filteredApps = sortedApps.filter { + $0.name.lowercased().contains(value.lowercased()) + } + } else { + filteredApps = sourceApps.filter { + $0.name.lowercased().contains(value.lowercased()) + } + } + } + } +} diff --git a/PlayCover/Views/StoreInfoAppView.swift b/PlayCover/Views/StoreInfoAppView.swift index 040e1740e..796bb0d8c 100644 --- a/PlayCover/Views/StoreInfoAppView.swift +++ b/PlayCover/Views/StoreInfoAppView.swift @@ -130,7 +130,7 @@ struct StoreInfoAppView: View { struct StoreInfoView: View { - @Binding var data: StoreAppData + @Binding var data: SourceAppsData var body: some View { List { diff --git a/PlayCover/en.lproj/Localizable.strings b/PlayCover/en.lproj/Localizable.strings index e2dcc971e..67f0034e7 100644 --- a/PlayCover/en.lproj/Localizable.strings +++ b/PlayCover/en.lproj/Localizable.strings @@ -35,6 +35,7 @@ "ipaLibrary.unavailable" = "The link is unavailable"; "ipaLibrary.alert.download" = "Are you sure you want to download this version of %@?"; "ipaLibrary.download" = "Download"; +"ipaLibrary.AlphabeticalSort" = "Sort source apps alphabetically"; "preferences.button.checkForUpdates" = "Check for Updates"; "preferences.tab.updates" = "Updates";