custom explore page + api

This commit is contained in:
leminlimez committed 2025-07-15 15:00:42 -04:00
1 parent 5a94ea73d9
commit 4d6b093ffd
10 files changed
+415 -49

No files matched your search

+79
View File
@@ -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)
}
}
}
}
@@ -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
}
}
+30
View File
@@ -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
}
}
}
@@ -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)
}
@@ -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)
}
}
+21 -6
View File
@@ -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",
@@ -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)
}
}
-18
View File
@@ -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<Void, any Error>? = 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")