Skip to content
This repository was archived by the owner on Mar 7, 2026. It is now read-only.

Commit 4ed96dc

Browse files
authored
Refactor AppsView and related models for public access
1 parent cc5a18e commit 4ed96dc

2 files changed

Lines changed: 363 additions & 489 deletions

File tree

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
import SwiftUI
2+
import Combine
3+
import Foundation
4+
5+
// MARK: - Models
6+
public struct AltSource: Decodable {
7+
let name: String?
8+
let subtitle: String?
9+
let iconURL: URL?
10+
let apps: [AltApp]?
11+
}
12+
13+
public struct AppVersion: Decodable {
14+
let version: String?
15+
let date: String?
16+
let downloadURL: URL?
17+
let size: Int?
18+
let minOSVersion: String?
19+
let localizedDescription: String?
20+
}
21+
22+
public struct AltApp: Decodable, Identifiable {
23+
public var id: String { bundleIdentifier }
24+
public let name: String
25+
public let bundleIdentifier: String
26+
public let developerName: String?
27+
public let subtitle: String?
28+
public let iconURL: URL?
29+
public let localizedDescription: String?
30+
public let versions: [AppVersion]?
31+
public let screenshotURLs: [URL]?
32+
}
33+
34+
// MARK: - ViewModel
35+
@MainActor
36+
final class RepoViewModel: ObservableObject {
37+
@Published var apps: [AltApp] = []
38+
@Published var isLoading: Bool = false
39+
@Published var errorMessage: String? = nil
40+
41+
private let sourceURLs: [URL] // <-- multiple sources
42+
43+
init(sourceURLs: [URL]) {
44+
self.sourceURLs = sourceURLs
45+
Task { await loadAllSources() }
46+
}
47+
48+
func loadAllSources() async {
49+
isLoading = true
50+
errorMessage = nil
51+
defer { isLoading = false }
52+
53+
var combinedApps: [AltApp] = []
54+
var errors: [String] = []
55+
56+
for url in sourceURLs {
57+
do {
58+
var request = URLRequest(url: url)
59+
request.setValue("AppTestersListView/1.0 (iOS)", forHTTPHeaderField: "User-Agent")
60+
61+
let (data, response) = try await URLSession.shared.data(for: request)
62+
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
63+
throw NSError(domain: "RepoFetcher", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP \(http.statusCode)"])
64+
}
65+
66+
let decoder = JSONDecoder()
67+
68+
if let source = try? decoder.decode(AltSource.self, from: data), let apps = source.apps {
69+
combinedApps.append(contentsOf: apps)
70+
continue
71+
}
72+
73+
if let appsArray = try? decoder.decode([AltApp].self, from: data) {
74+
combinedApps.append(contentsOf: appsArray)
75+
continue
76+
}
77+
78+
if let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any],
79+
let appsFragment = jsonObject["apps"] {
80+
let fragmentData = try JSONSerialization.data(withJSONObject: appsFragment)
81+
let appsArray = try decoder.decode([AltApp].self, from: fragmentData)
82+
combinedApps.append(contentsOf: appsArray)
83+
continue
84+
}
85+
86+
throw NSError(domain: "RepoFetcher", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unexpected JSON format."])
87+
88+
} catch {
89+
errors.append("Failed \(url): \(error.localizedDescription)")
90+
}
91+
}
92+
93+
self.apps = combinedApps
94+
if !errors.isEmpty {
95+
self.errorMessage = errors.joined(separator: "\n")
96+
}
97+
}
98+
99+
func refresh() {
100+
Task { await loadAllSources() }
101+
}
102+
}
103+
104+
// MARK: - RetryAsyncImage (stable layout + retry)
105+
struct RetryAsyncImage<Content: View, Placeholder: View, Failure: View>: View {
106+
let url: URL?
107+
let maxAttempts: Int
108+
let size: CGSize?
109+
let content: (Image) -> Content
110+
let placeholder: () -> Placeholder
111+
let failure: () -> Failure
112+
113+
@State private var currentAttempt: Int = 0
114+
@State private var retryTrigger: UUID = UUID()
115+
116+
private var modifiedURL: URL? {
117+
guard let url = url else { return nil }
118+
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
119+
// inject attempt param so AsyncImage re-fetches when attempt changes
120+
var query = components?.queryItems ?? []
121+
query.removeAll(where: { $0.name == "retryAttempt" })
122+
query.append(URLQueryItem(name: "retryAttempt", value: "\(currentAttempt)"))
123+
components?.queryItems = query
124+
return components?.url
125+
}
126+
127+
init(
128+
url: URL?,
129+
size: CGSize? = nil,
130+
maxAttempts: Int = 3,
131+
@ViewBuilder content: @escaping (Image) -> Content,
132+
@ViewBuilder placeholder: @escaping () -> Placeholder,
133+
@ViewBuilder failure: @escaping () -> Failure
134+
) {
135+
self.url = url
136+
self.size = size
137+
self.maxAttempts = maxAttempts
138+
self.content = content
139+
self.placeholder = placeholder
140+
self.failure = failure
141+
}
142+
143+
var body: some View {
144+
let frameView = Group {
145+
if let modifiedURL = modifiedURL {
146+
AsyncImage(url: modifiedURL) { phase in
147+
switch phase {
148+
case .empty:
149+
placeholder()
150+
case .success(let image):
151+
content(image)
152+
case .failure:
153+
// If we still have attempts left, show placeholder and schedule a retry
154+
if currentAttempt < maxAttempts - 1 {
155+
placeholder()
156+
.task {
157+
// small delay to avoid tight loop and give network a moment
158+
try? await Task.sleep(nanoseconds: 250_000_000)
159+
await MainActor.run {
160+
currentAttempt += 1
161+
retryTrigger = UUID()
162+
}
163+
}
164+
} else {
165+
failure()
166+
}
167+
@unknown default:
168+
placeholder()
169+
}
170+
}
171+
} else {
172+
failure()
173+
}
174+
}
175+
176+
if let size = size {
177+
frameView
178+
.frame(width: size.width, height: size.height)
179+
.clipped()
180+
} else {
181+
frameView
182+
}
183+
}
184+
}
185+
186+
// MARK: - AppsView
187+
public struct AppsView: View {
188+
@StateObject private var vm: RepoViewModel
189+
@State private var searchText: String = ""
190+
@FocusState private var searchFieldFocused: Bool
191+
@State private var selectedApp: AltApp? = nil
192+
193+
public init(repoURLs: [URL] = [URL(string: "https://repository.apptesters.org/")!]) {
194+
_vm = StateObject(wrappedValue: RepoViewModel(sourceURLs: repoURLs))
195+
}
196+
197+
private var filteredApps: [AltApp] {
198+
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
199+
guard !query.isEmpty else { return vm.apps }
200+
let lowered = query.lowercased()
201+
return vm.apps.filter { app in
202+
if app.name.lowercased().contains(lowered) { return true }
203+
if app.bundleIdentifier.lowercased().contains(lowered) { return true }
204+
if let dev = app.developerName, dev.lowercased().contains(lowered) { return true }
205+
if let sub = app.subtitle, sub.lowercased().contains(lowered) { return true }
206+
return false
207+
}
208+
}
209+
210+
public var body: some View {
211+
VStack(spacing: 0) {
212+
213+
// Search Bar (hidden during initial load)
214+
if !(vm.isLoading && vm.apps.isEmpty) {
215+
HStack {
216+
Image(systemName: "magnifyingglass")
217+
.foregroundColor(.secondary)
218+
TextField("Search apps, developer or bundle ID", text: $searchText)
219+
.textInputAutocapitalization(.never)
220+
.disableAutocorrection(true)
221+
.focused($searchFieldFocused)
222+
.submitLabel(.search)
223+
if !searchText.isEmpty {
224+
Button(action: { searchText = "" }) {
225+
Image(systemName: "xmark.circle.fill")
226+
.foregroundColor(.secondary)
227+
}
228+
.buttonStyle(.plain)
229+
}
230+
}
231+
.padding(10)
232+
.background(.regularMaterial)
233+
.cornerRadius(10)
234+
.padding(.horizontal)
235+
.padding(.top, 8)
236+
}
237+
238+
// Content
239+
Group {
240+
if vm.isLoading && vm.apps.isEmpty {
241+
VStack(spacing: 12) {
242+
ProgressView()
243+
Text("Loading apps...")
244+
.font(.subheadline)
245+
.foregroundColor(.secondary)
246+
}
247+
.padding()
248+
} else if let error = vm.errorMessage, vm.apps.isEmpty {
249+
VStack(spacing: 12) {
250+
Text("Error")
251+
.font(.headline)
252+
Text(error)
253+
.font(.subheadline)
254+
.foregroundColor(.secondary)
255+
.multilineTextAlignment(.center)
256+
Button("Retry") { vm.refresh() }
257+
.padding(.top, 8)
258+
}
259+
.padding()
260+
} else {
261+
List(filteredApps) { app in
262+
Button {
263+
selectedApp = app
264+
} label: {
265+
AppRowView(app: app)
266+
}
267+
.buttonStyle(.plain)
268+
}
269+
.listStyle(PlainListStyle())
270+
.refreshable { vm.refresh() }
271+
}
272+
}
273+
.padding(.top, 8)
274+
}
275+
.sheet(item: $selectedApp) { app in
276+
AppDetailView(app: app)
277+
}
278+
.toolbar {
279+
ToolbarItem(placement: .navigationBarTrailing) {
280+
Button(action: { vm.refresh() }) {
281+
Image(systemName: "arrow.clockwise")
282+
}
283+
.help("Refresh repository")
284+
}
285+
}
286+
}
287+
}
288+
289+
// MARK: - AppRowView
290+
private struct AppRowView: View {
291+
let app: AltApp
292+
private let iconSize = CGSize(width: 48, height: 48)
293+
294+
var body: some View {
295+
HStack(spacing: 12) {
296+
// Icon column: always reserves space so text doesn't shift
297+
ZStack {
298+
// stable background so we always have a visible placeholder area
299+
RoundedRectangle(cornerRadius: 10)
300+
.fill(Color.gray.opacity(0.12))
301+
.frame(width: iconSize.width, height: iconSize.height)
302+
303+
if let iconURL = app.iconURL {
304+
RetryAsyncImage(
305+
url: iconURL,
306+
size: iconSize,
307+
maxAttempts: 3,
308+
content: { image in
309+
image
310+
.resizable()
311+
.scaledToFill()
312+
.frame(width: iconSize.width, height: iconSize.height)
313+
.clipShape(RoundedRectangle(cornerRadius: 10))
314+
},
315+
placeholder: {
316+
ProgressView()
317+
.frame(width: iconSize.width, height: iconSize.height)
318+
},
319+
failure: {
320+
Image(systemName: "app")
321+
.resizable()
322+
.scaledToFit()
323+
.frame(width: 28, height: 28)
324+
.foregroundColor(.secondary)
325+
}
326+
)
327+
} else {
328+
Image(systemName: "app")
329+
.resizable()
330+
.scaledToFit()
331+
.frame(width: 28, height: 28)
332+
.foregroundColor(.secondary)
333+
}
334+
}
335+
.frame(width: iconSize.width, height: iconSize.height) // crucial: fixed width
336+
337+
VStack(alignment: .leading, spacing: 2) {
338+
Text(app.name)
339+
.font(.headline)
340+
.lineLimit(1)
341+
.layoutPriority(1) // prevent truncation due to flexible spacing
342+
if let subtitle = app.subtitle, !subtitle.isEmpty {
343+
Text(subtitle)
344+
.font(.subheadline)
345+
.foregroundColor(.secondary)
346+
.lineLimit(1)
347+
} else if let dev = app.developerName {
348+
Text(dev)
349+
.font(.subheadline)
350+
.foregroundColor(.secondary)
351+
.lineLimit(1)
352+
}
353+
}
354+
355+
Spacer()
356+
357+
Image(systemName: "chevron.right")
358+
.foregroundColor(.secondary)
359+
}
360+
.padding(.vertical, 6)
361+
.contentShape(Rectangle())
362+
}
363+
}

0 commit comments

Comments
 (0)