From 4d6b093ffdd63789fc4e1dc371f988223b530bad Mon Sep 17 00:00:00 2001 From: leminlimez Date: Tue, 15 Jul 2025 15:00:42 -0400 Subject: [PATCH] custom explore page + api --- Pocket Poster.xcodeproj/project.pbxproj | 17 ++ .../xcshareddata/swiftpm/Package.resolved | 11 +- Pocket Poster/API/CowabungaAPI.swift | 79 +++++++ Pocket Poster/API/DownloadableWallpaper.swift | 27 +++ Pocket Poster/Error Codes/APIError.swift | 30 +++ .../Extensions/URLCache+imageCache.swift | 14 ++ .../Extensions/Views/CornerRadius++.swift | 25 ++ Pocket Poster/Localizable.xcstrings | 27 ++- .../Views/NavigatorViews/ExploreView.swift | 216 ++++++++++++++++-- Pocket Poster/Views/SettingsView.swift | 18 -- 10 files changed, 415 insertions(+), 49 deletions(-) create mode 100644 Pocket Poster/API/CowabungaAPI.swift create mode 100644 Pocket Poster/API/DownloadableWallpaper.swift create mode 100644 Pocket Poster/Error Codes/APIError.swift create mode 100644 Pocket Poster/Extensions/URLCache+imageCache.swift create mode 100644 Pocket Poster/Extensions/Views/CornerRadius++.swift diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index 5d45780..cc78c52 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; }; 6F09BA332E00808300CDE89C /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09BA322E00808300CDE89C /* Dynamic */; }; + 6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB105542E26D3EC00185A5C /* CachedAsyncImage */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -31,6 +32,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */, 6F09BA332E00808300CDE89C /* Dynamic in Frameworks */, 6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */, ); @@ -77,6 +79,7 @@ packageProductDependencies = ( 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */, 6F09BA322E00808300CDE89C /* Dynamic */, + 6FB105542E26D3EC00185A5C /* CachedAsyncImage */, ); productName = "Pocket Poster"; productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */; @@ -109,6 +112,7 @@ packageReferences = ( 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */, + 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */; @@ -366,6 +370,14 @@ minimumVersion = 1.2.0; }; }; + 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.1.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -379,6 +391,11 @@ package = 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */; productName = Dynamic; }; + 6FB105542E26D3EC00185A5C /* CachedAsyncImage */ = { + isa = XCSwiftPackageProductDependency; + package = 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */; + productName = CachedAsyncImage; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6F09B7952DEB694B00CDE89C /* Project object */; diff --git a/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d2be016..8c6e3da 100644 --- a/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b781b75a76813cee1292db0b540a9eecd3df93961ced8a0063095503735dadb2", + "originHash" : "73a600cf677d3bb2b929c60349923a2a4e20e6a9ef10509bf043d2ad2229ca95", "pins" : [ { "identity" : "dynamic", @@ -10,6 +10,15 @@ "version" : "1.2.0" } }, + { + "identity" : "swiftui-cached-async-image", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lorenzofiamingo/swiftui-cached-async-image", + "state" : { + "revision" : "467a3d17479887943ab917a379e62bbaff60ac8a", + "version" : "2.1.1" + } + }, { "identity" : "zipfoundation", "kind" : "remoteSourceControl", diff --git a/Pocket Poster/API/CowabungaAPI.swift b/Pocket Poster/API/CowabungaAPI.swift new file mode 100644 index 0000000..07bb1a0 --- /dev/null +++ b/Pocket Poster/API/CowabungaAPI.swift @@ -0,0 +1,79 @@ +// +// CowabungaAPI.swift +// Pocket Poster +// +// Created by lemin on 7/15/25. +// + +import UIKit + +enum FilterType: String, CaseIterable { + case random = "Random" + case newest = "Newest" + case oldest = "Oldest" +} + +class CowabungaAPI: ObservableObject { + + static let shared = CowabungaAPI() + + var serverURL = "" + var session = URLSession.shared + + func fetchWallpapers(type: DownloadableWallpaper.WallpaperType) async throws -> [DownloadableWallpaper] { + let request = URLRequest(url: .init(string: serverURL + "wallpapers-\(type.rawValue).json")!) + + let (data, response) = try await session.data(for: request) as! (Data, HTTPURLResponse) + guard response.statusCode == 200 else { throw APIError.connectionFailed } + let wallpapers = try JSONDecoder().decode([DownloadableWallpaper].self, from: data) + + for i in wallpapers.indices { + wallpapers[i].type = type + } + + return wallpapers + } + + func filterWallpapers(wallpapers: [DownloadableWallpaper], filterType: FilterType) -> [DownloadableWallpaper] { + var filtered = wallpapers + if filterType == FilterType.newest { + filtered = filtered.reversed() + } else if filterType == FilterType.random { + filtered = filtered.shuffled() + } + return filtered + } + + func getCommitHash() async throws -> String { + let request = URLRequest(url: .init(string: serverURL + "https://api.github.com/repos/SerStars/nugget-wallpapers/commits/main")!) + + let (data, response) = try await session.data(for: request) as! (Data, HTTPURLResponse) + guard response.statusCode == 200 else { throw APIError.connectionFailed } + guard let repoinfo = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { throw APIError.repoHashError } + guard let hash = repoinfo["sha"] as? String else { throw APIError.repoHashError } + return hash + } + + func getDownloadURLForWallpaper(wallpaper: DownloadableWallpaper) -> URL { + if wallpaper.url.hasPrefix("https://") { + URL(string: wallpaper.url)! + } else { + URL(string: serverURL + wallpaper.url)! + } + } + + func getPreviewURLForWallpaper(wallpaper: DownloadableWallpaper) -> URL { + URL(string: serverURL + wallpaper.preview)! + } + + init() { + Task { + do { + let hash = try await getCommitHash() + serverURL = "https://raw.githubusercontent.com/SerStars/nugget-wallpapers/\(hash)/" + } catch { + await UIApplication.shared.alert(body: error.localizedDescription) + } + } + } +} diff --git a/Pocket Poster/API/DownloadableWallpaper.swift b/Pocket Poster/API/DownloadableWallpaper.swift new file mode 100644 index 0000000..9b070ee --- /dev/null +++ b/Pocket Poster/API/DownloadableWallpaper.swift @@ -0,0 +1,27 @@ +// +// DownloadableWallpaper.swift +// Pocket Poster +// +// Created by lemin on 7/15/25. +// + +class DownloadableWallpaper: Identifiable, Codable { + var name: String + var description: String + var url: String + var preview: String + var authors: String + var type: WallpaperType? + + init(name: String, description: String, authors: String, preview: String, url: String, version: String) { + self.name = name + self.description = description + self.authors = authors + self.preview = preview + self.url = url + } + + enum WallpaperType: String, Codable { + case custom, apple, template + } +} diff --git a/Pocket Poster/Error Codes/APIError.swift b/Pocket Poster/Error Codes/APIError.swift new file mode 100644 index 0000000..044f9b1 --- /dev/null +++ b/Pocket Poster/Error Codes/APIError.swift @@ -0,0 +1,30 @@ +// +// APIError.swift +// Pocket Poster +// +// Created by lemin on 7/15/25. +// + +import Foundation + +enum APIError: LocalizedError { + // Throw when cannot connect to server + case connectionFailed + + // Throw when it fails to get the repo hash + case repoHashError + + // Throw in all other cases + case unexpected(info: String) + + public var errorDescription: String? { + switch self { + case .connectionFailed: + return NSLocalizedString("Could not connect to server", comment: "") + case .repoHashError: + return NSLocalizedString("Unable to obtain repo hash. Maybe update to the latest version?", comment: "") + case .unexpected(let info): + return info + } + } +} diff --git a/Pocket Poster/Extensions/URLCache+imageCache.swift b/Pocket Poster/Extensions/URLCache+imageCache.swift new file mode 100644 index 0000000..a48257e --- /dev/null +++ b/Pocket Poster/Extensions/URLCache+imageCache.swift @@ -0,0 +1,14 @@ +// +// URLCache+imageCache.swift +// Cowabunga +// +// Created by sourcelocation on 02/02/2023. +// + +import CachedAsyncImage +import Foundation + +extension URLCache { + + static let imageCache = URLCache(memoryCapacity: 512*1000*1000, diskCapacity: 10*1000*1000*1000) +} diff --git a/Pocket Poster/Extensions/Views/CornerRadius++.swift b/Pocket Poster/Extensions/Views/CornerRadius++.swift new file mode 100644 index 0000000..4aaa8da --- /dev/null +++ b/Pocket Poster/Extensions/Views/CornerRadius++.swift @@ -0,0 +1,25 @@ +// +// CornerRadius++.swift +// Cowabunga +// +// Created by sourcelocation on 30/01/2023. +// + +import SwiftUI + +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape( RoundedCorner(radius: radius, corners: corners) ) + } +} + +struct RoundedCorner: Shape { + + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } +} diff --git a/Pocket Poster/Localizable.xcstrings b/Pocket Poster/Localizable.xcstrings index b1d33db..faafe0b 100644 --- a/Pocket Poster/Localizable.xcstrings +++ b/Pocket Poster/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, "Actions" : { "localizations" : { "ar" : { @@ -462,6 +465,9 @@ } } } + }, + "Apple" : { + }, "Apply" : { "localizations" : { @@ -1438,6 +1444,9 @@ } } } + }, + "Could not connect to server" : { + }, "Could not download wallpaper!" : { "localizations" : { @@ -2210,6 +2219,9 @@ } } } + }, + "Custom" : { + }, "Detect" : { "localizations" : { @@ -4229,6 +4241,9 @@ }, "Failed to fetch CarPlay wallpapers" : { + }, + "Failed to fetch wallpapers" : { + }, "Failed to import tendies" : { "localizations" : { @@ -4537,9 +4552,6 @@ } } } - }, - "Failed to load explore page." : { - }, "Fallback Shortcut Creator" : { "localizations" : { @@ -4848,6 +4860,9 @@ } } } + }, + "Filter Wallpapers" : { + }, "Find Wallpapers" : { "localizations" : { @@ -8749,9 +8764,6 @@ } } } - }, - "Retry" : { - }, "Select Tendies" : { "localizations" : { @@ -10603,6 +10615,9 @@ } } } + }, + "Unable to obtain repo hash. Maybe update to the latest version?" : { + }, "Update Available" : { "comment" : "app update available on GitHub", diff --git a/Pocket Poster/Views/NavigatorViews/ExploreView.swift b/Pocket Poster/Views/NavigatorViews/ExploreView.swift index f2bc682..4f4385d 100644 --- a/Pocket Poster/Views/NavigatorViews/ExploreView.swift +++ b/Pocket Poster/Views/NavigatorViews/ExploreView.swift @@ -6,41 +6,209 @@ // import SwiftUI +import CachedAsyncImage + +let MIN_SIZE: CGFloat = 165 struct ExploreView: View { - @State private var isLoading = true - @State private var error: Error? = nil + @ObservedObject var cowabungaAPI = CowabungaAPI.shared -#if DEBUG - @AppStorage("exploreLink") var exploreLink: String = DownloadManager.exploreLink -#endif + // lazyvgrid + @State private var gridItemLayout = [GridItem(.adaptive(minimum: MIN_SIZE))] + @State private var wallpapers: [DownloadableWallpaper] = [] + + @State var wallpaperTypeSelected = 0 + @State var wallpaperTypeShown = DownloadableWallpaper.WallpaperType.custom + + @State var filterType: FilterType = .random + @State var searchTerm: String = "" var body: some View { - ZStack { -#if DEBUG - let websiteURL = URL(string: exploreLink) -#else - let websiteURL = URL(string: DownloadManager.exploreLink) -#endif - if let error = error { + NavigationView { + ScrollView { + PullToRefresh(coordinateSpaceName: "pullToRefresh") { + // refresh + wallpapers.removeAll() + Haptic.shared.play(.light) + //URLCache.imageCache.removeAllCachedResponses() + loadWallpapers() + } VStack { - Text(error.localizedDescription) - .foregroundStyle(.red) + Picker("", selection: $wallpaperTypeSelected) { + Text("Custom").tag(0) + Text("Apple").tag(1) + } + .pickerStyle(.segmented) + } + .padding(.horizontal, 24) + .padding(.bottom, 10) + + if wallpapers.isEmpty { + ProgressView() + .scaleEffect(1.75) + .navigationTitle("Explore") + } else { + LazyVGrid(columns: gridItemLayout) { + ForEach(wallpapers) { wallpaper in + if searchTerm == "" || wallpaper.name.lowercased().contains(searchTerm.lowercased()) || wallpaper.authors.lowercased().contains(searchTerm.lowercased()) { + Button(action: { + DownloadManager.shared.startTendiesDownload(for: cowabungaAPI.getDownloadURLForWallpaper(wallpaper: wallpaper)) + }) { + VStack(spacing: 0) { + CachedAsyncImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity) + .cornerRadius(10, corners: .topLeft) + .cornerRadius(10, corners: .topRight) + } placeholder: { + Color.gray + .frame(height: MIN_SIZE) + } + HStack { + VStack(spacing: 4) { + HStack { + Text(wallpaper.name) + .foregroundStyle(Color(uiColor: .label)) + .minimumScaleFactor(0.5) + Spacer() + } + HStack { + Text(wallpaper.authors) + .foregroundColor(.secondary) + .font(.caption) + .minimumScaleFactor(0.5) + Spacer() + } + } + .lineLimit(1) + Spacer() + Image(systemName: "arrow.down.circle") + .foregroundColor(.blue) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(height: 58) + } + } + .frame(minWidth: MIN_SIZE) + .background(Color(uiColor: .secondarySystemBackground)) + .cornerRadius(10) + .padding(4) + } + } + } + .padding() + } + } + .searchable(text: $searchTerm) + .coordinateSpace(name: "pullToRefresh") + .navigationTitle("Explore") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { Button(action: { - isLoading = true - self.error = nil + showFilterChangerPopup() }) { - Text("Retry") + Image(systemName: "line.3.horizontal.decrease.circle") } } - } else if let url = websiteURL { - WallpaperWebView(url, isLoading: $isLoading, error: $error) - if isLoading { - ProgressView() - } - } else { - Text("Failed to load explore page.") + } + } + .onAppear { + loadWallpapers() + } + .onChange(of: wallpaperTypeSelected) { newValue in + let map = [0: DownloadableWallpaper.WallpaperType.custom, 1: .apple, 2: .template] + wallpaperTypeShown = map[newValue]! + + wallpapers.removeAll() + loadWallpapers() + } + } + + func loadWallpapers() { + Task { + do { + wallpapers = try await cowabungaAPI.fetchWallpapers(type: wallpaperTypeShown) + wallpapers = cowabungaAPI.filterWallpapers(wallpapers: wallpapers, filterType: filterType) + } catch { + UIApplication.shared.alert(title: NSLocalizedString("Failed to fetch wallpapers", comment: ""), body: error.localizedDescription) } } } + + func showFilterChangerPopup() { + // create and configure alert controller + let alert = UIAlertController(title: NSLocalizedString("Filter Wallpapers", comment: ""), message: "", preferredStyle: .actionSheet) + + // create the actions + for type in FilterType.allCases { + let newAction = UIAlertAction(title: type.rawValue, style: .default) { (action) in + // apply the filter type + filterType = type + wallpapers.removeAll() + loadWallpapers() + } + if filterType == type { + // add a check mark + newAction.setValue(true, forKey: "checked") + } + alert.addAction(newAction) + } + + let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { (action) in + // cancels the action + } + + // add the actions + alert.addAction(cancelAction) + + let view: UIView = UIApplication.shared.windows.first!.rootViewController!.view + // present popover for iPads + alert.popoverPresentationController?.sourceView = view // prevents crashing on iPads + alert.popoverPresentationController?.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY, width: 0, height: 0) // show up at center bottom on iPads + + // present the alert + UIApplication.shared.windows.first?.rootViewController?.present(alert, animated: true) + } +} + +struct PullToRefresh: View { + var coordinateSpaceName: String + var onRefresh: ()->Void + + @State var needRefresh: Bool = false + + var body: some View { + GeometryReader { geo in + if (geo.frame(in: .named(coordinateSpaceName)).midY > 50) { + Spacer() + .onAppear { + needRefresh = true + } + } else if (geo.frame(in: .named(coordinateSpaceName)).maxY < 10) { + Spacer() + .onAppear { + if needRefresh { + needRefresh = false + onRefresh() + } + } + } + HStack { + Spacer() + if needRefresh { + ProgressView() + .scaleEffect(1.75) + .onAppear { + Haptic.shared.play(.light) + } + } else { + Text("") + } + Spacer() + } + }.padding(.top, -50) + } } diff --git a/Pocket Poster/Views/SettingsView.swift b/Pocket Poster/Views/SettingsView.swift index 9e1abc0..d87ee9d 100644 --- a/Pocket Poster/Views/SettingsView.swift +++ b/Pocket Poster/Views/SettingsView.swift @@ -13,11 +13,6 @@ struct SettingsView: View { @AppStorage("cpHash") var cpHash: String = "" // CarPlay hash @AppStorage("ignoreDurationLimit") var ignoreDurationLimit: Bool = false - // DEBUG -#if DEBUG - @AppStorage("exploreLink") var exploreLink: String = DownloadManager.exploreLink -#endif - @State var checkingForHash: Bool = false @State var hashCheckTask: Task? = nil @@ -58,19 +53,6 @@ struct SettingsView: View { Label("App Hash", systemImage: "lock.app.dashed") } -#if DEBUG - Section { - VStack { - Text(String("Explore Page URL")) - TextField(String("Enter Explore Page URL"), text: $exploreLink) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .font(.system(.body, design: .monospaced)) - } - } header: { - Label(String("Debug"), systemImage: "ant") - } -#endif - Section { Toggle(isOn: $ignoreDurationLimit, label: { Label("Disable Video Duration Limit", systemImage: "ruler")