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

Commit ff40092

Browse files
authored
Bug fixes
1 parent 2c36a9e commit ff40092

1 file changed

Lines changed: 148 additions & 144 deletions

File tree

Sources/prostore/views/appsView.swift

Lines changed: 148 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,7 @@ struct AltSource: Decodable {
99
let apps: [AltApp]?
1010
}
1111

12-
struct AltApp: Decodable, Identifiable, Equatable {
13-
var id: String { bundleIdentifier }
14-
let name: String
15-
let bundleIdentifier: String
16-
let developerName: String?
17-
let subtitle: String?
18-
let iconURL: URL?
19-
let versions: [AppVersion]?
20-
21-
var latestDownloadURL: URL? { versions?.first?.downloadURL }
22-
}
23-
24-
struct AppVersion: Decodable, Equatable {
12+
struct AppVersion: Decodable {
2513
let version: String?
2614
let buildVersion: String?
2715
let date: String?
@@ -31,37 +19,34 @@ struct AppVersion: Decodable, Equatable {
3119
let maxOSVersion: String?
3220
}
3321

34-
// MARK: - Image Cache
35-
@MainActor
36-
final class ImageCache {
37-
static let shared = ImageCache()
38-
private var cache: [URL: Image] = [:]
22+
struct AltApp: Decodable, Identifiable {
23+
var id: String { bundleIdentifier }
24+
let name: String
25+
let bundleIdentifier: String
26+
let developerName: String?
27+
let subtitle: String?
28+
let iconURL: URL?
29+
let versions: [AppVersion]?
3930

40-
func image(for url: URL) -> Image? { cache[url] }
41-
func set(_ image: Image, for url: URL) { cache[url] = image }
31+
var latestDownloadURL: URL? {
32+
versions?.first?.downloadURL
33+
}
4234
}
4335

4436
// MARK: - ViewModel
4537
@MainActor
4638
final class RepoViewModel: ObservableObject {
47-
@Published private(set) var apps: [AltApp] = []
48-
@Published private(set) var filteredApps: [AltApp] = []
39+
@Published var apps: [AltApp] = []
4940
@Published var isLoading: Bool = false
5041
@Published var errorMessage: String? = nil
5142

52-
@Published var searchText: String = "" {
53-
didSet { debounceSearch() }
54-
}
55-
56-
private var searchTask: Task<Void, Never>?
5743
private let sourceURL: URL
5844

5945
init(sourceURL: URL) {
6046
self.sourceURL = sourceURL
6147
Task { await load() }
6248
}
6349

64-
/// Load apps
6550
func load() async {
6651
isLoading = true
6752
errorMessage = nil
@@ -73,182 +58,201 @@ final class RepoViewModel: ObservableObject {
7358

7459
let (data, response) = try await URLSession.shared.data(for: request)
7560
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
76-
throw NSError(domain: "RepoFetcher", code: http.statusCode,
77-
userInfo: [NSLocalizedDescriptionKey: "HTTP \(http.statusCode)"])
61+
throw NSError(domain: "RepoFetcher", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP \(http.statusCode)"])
7862
}
7963

8064
let decoder = JSONDecoder()
81-
let newApps: [AltApp]
8265

8366
if let source = try? decoder.decode(AltSource.self, from: data), let apps = source.apps {
84-
newApps = apps
85-
} else if let appsArray = try? decoder.decode([AltApp].self, from: data) {
86-
newApps = appsArray
87-
} else if let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any],
88-
let appsFragment = jsonObject["apps"] {
67+
self.apps = apps
68+
return
69+
}
70+
71+
if let appsArray = try? decoder.decode([AltApp].self, from: data) {
72+
self.apps = appsArray
73+
return
74+
}
75+
76+
if let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any],
77+
let appsFragment = jsonObject["apps"] {
8978
let fragmentData = try JSONSerialization.data(withJSONObject: appsFragment)
90-
newApps = try decoder.decode([AltApp].self, from: fragmentData)
91-
} else {
92-
throw NSError(domain: "RepoFetcher", code: -1,
93-
userInfo: [NSLocalizedDescriptionKey: "Unexpected JSON format."])
79+
let appsArray = try decoder.decode([AltApp].self, from: fragmentData)
80+
self.apps = appsArray
81+
return
9482
}
9583

96-
// Diff update to avoid full redraw
97-
if newApps != apps { apps = newApps }
98-
filterApps()
84+
throw NSError(domain: "RepoFetcher", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unexpected JSON format."])
9985

10086
} catch {
10187
self.errorMessage = "Failed to load repository: \(error.localizedDescription)"
10288
self.apps = []
103-
self.filteredApps = []
104-
}
105-
}
106-
107-
func refresh() { Task { await load() } }
108-
109-
private func debounceSearch() {
110-
searchTask?.cancel()
111-
searchTask = Task { [weak self] in
112-
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
113-
await self?.filterApps()
11489
}
11590
}
11691

117-
private func filterApps() async {
118-
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
119-
if query.isEmpty {
120-
filteredApps = apps
121-
return
122-
}
123-
filteredApps = apps.filter { app in
124-
app.name.lowercased().contains(query)
125-
|| app.bundleIdentifier.lowercased().contains(query)
126-
|| app.developerName?.lowercased().contains(query) == true
127-
|| app.subtitle?.lowercased().contains(query) == true
128-
}
92+
func refresh() {
93+
Task { await load() }
12994
}
13095
}
13196

132-
// MARK: - View
97+
// MARK: - AppsView
13398
public struct AppsView: View {
13499
@StateObject private var vm: RepoViewModel
100+
101+
@State private var searchText: String = ""
135102
@FocusState private var searchFieldFocused: Bool
136103

137104
public init(repoURL: URL = URL(string: "https://repository.apptesters.org/")!) {
138105
_vm = StateObject(wrappedValue: RepoViewModel(sourceURL: repoURL))
139106
}
140107

141-
public var body: some View {
142-
VStack(spacing: 0) {
143-
searchBar
144-
content
145-
}
146-
.padding(.horizontal)
147-
.toolbar {
148-
ToolbarItem(placement: .navigationBarTrailing) {
149-
Button(action: { vm.refresh() }) { Image(systemName: "arrow.clockwise") }
150-
}
108+
private var filteredApps: [AltApp] {
109+
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
110+
guard !query.isEmpty else { return vm.apps }
111+
let lowered = query.lowercased()
112+
return vm.apps.filter { app in
113+
if app.name.lowercased().contains(lowered) { return true }
114+
if app.bundleIdentifier.lowercased().contains(lowered) { return true }
115+
if let dev = app.developerName, dev.lowercased().contains(lowered) { return true }
116+
if let sub = app.subtitle, sub.lowercased().contains(lowered) { return true }
117+
return false
151118
}
152119
}
153120

154-
private var searchBar: some View {
155-
HStack {
156-
Image(systemName: "magnifyingglass").foregroundColor(.secondary)
157-
TextField("Search apps, developer or bundle ID", text: $vm.searchText)
158-
.textInputAutocapitalization(.never)
159-
.disableAutocorrection(true)
160-
.focused($searchFieldFocused)
161-
.submitLabel(.search)
162-
if !vm.searchText.isEmpty {
163-
Button(action: { vm.searchText = "" }) {
164-
Image(systemName: "xmark.circle.fill").foregroundColor(.secondary)
165-
}.buttonStyle(.plain)
121+
public var body: some View {
122+
VStack(spacing: 0) {
123+
124+
// Search Bar
125+
HStack {
126+
Image(systemName: "magnifyingglass")
127+
.foregroundColor(.secondary)
128+
TextField("Search apps, developer or bundle ID", text: $searchText)
129+
.textInputAutocapitalization(.never)
130+
.disableAutocorrection(true)
131+
.focused($searchFieldFocused)
132+
.submitLabel(.search)
133+
if !searchText.isEmpty {
134+
Button(action: { searchText = "" }) {
135+
Image(systemName: "xmark.circle.fill")
136+
.foregroundColor(.secondary)
137+
}
138+
.buttonStyle(.plain)
139+
}
166140
}
167-
}
168-
.padding(10)
169-
.background(.regularMaterial)
170-
.cornerRadius(10)
171-
.padding(.top, 8)
172-
}
173-
174-
@ViewBuilder
175-
private var content: some View {
176-
if vm.isLoading && vm.apps.isEmpty {
177-
VStack(spacing: 12) {
178-
ProgressView()
179-
Text("Loading apps...").font(.subheadline).foregroundColor(.secondary)
180-
}.padding()
181-
} else if let error = vm.errorMessage, vm.apps.isEmpty {
182-
VStack(spacing: 12) {
183-
Text("Error").font(.headline)
184-
Text(error).font(.subheadline).foregroundColor(.secondary).multilineTextAlignment(.center)
185-
Button("Retry") { vm.refresh() }.padding(.top, 8)
186-
}.padding()
187-
} else {
188-
ScrollView {
189-
LazyVStack {
190-
ForEach(vm.filteredApps) { app in
141+
.padding(10)
142+
.background(.regularMaterial)
143+
.cornerRadius(10)
144+
.padding(.horizontal)
145+
.padding(.top, 8)
146+
147+
// Content
148+
Group {
149+
if vm.isLoading && vm.apps.isEmpty {
150+
VStack(spacing: 12) {
151+
ProgressView()
152+
Text("Loading apps...")
153+
.font(.subheadline)
154+
.foregroundColor(.secondary)
155+
}
156+
.padding()
157+
} else if let error = vm.errorMessage, vm.apps.isEmpty {
158+
VStack(spacing: 12) {
159+
Text("Error")
160+
.font(.headline)
161+
Text(error)
162+
.font(.subheadline)
163+
.foregroundColor(.secondary)
164+
.multilineTextAlignment(.center)
165+
Button("Retry") { vm.refresh() }
166+
.padding(.top, 8)
167+
}
168+
.padding()
169+
} else {
170+
List(filteredApps) { app in
191171
AppRowView(app: app)
192-
.transition(.opacity)
193172
}
173+
.listStyle(PlainListStyle())
174+
.refreshable { vm.refresh() }
194175
}
195176
}
196-
.refreshable { vm.refresh() }
177+
.padding(.top, 8)
178+
}
179+
.toolbar {
180+
ToolbarItem(placement: .navigationBarTrailing) {
181+
Button(action: { vm.refresh() }) {
182+
Image(systemName: "arrow.clockwise")
183+
}
184+
.help("Refresh repository")
185+
}
197186
}
198187
}
199188
}
200189

201-
// MARK: - Row
190+
// MARK: - AppRowView
202191
private struct AppRowView: View {
203192
let app: AltApp
204193

205194
var body: some View {
206195
HStack(spacing: 12) {
207196
if let iconURL = app.iconURL {
208-
if let cached = ImageCache.shared.image(for: iconURL) {
209-
cached
210-
.resizable()
211-
.scaledToFill()
212-
.frame(width: 48, height: 48)
213-
.clipShape(RoundedRectangle(cornerRadius: 10))
214-
} else {
215-
AsyncImage(url: iconURL) { phase in
216-
switch phase {
217-
case .empty:
218-
ProgressView().frame(width: 48, height: 48)
219-
case .success(let image):
220-
let img = image.resizable()
221-
ImageCache.shared.set(img, for: iconURL)
222-
img.scaledToFill().frame(width: 48, height: 48)
223-
.clipShape(RoundedRectangle(cornerRadius: 10))
224-
case .failure:
225-
Image(systemName: "app").resizable().scaledToFit()
226-
.frame(width: 36, height: 36).foregroundColor(.secondary)
227-
@unknown default: EmptyView()
228-
}
197+
AsyncImage(url: iconURL) { phase in
198+
switch phase {
199+
case .empty:
200+
ProgressView()
201+
.frame(width: 48, height: 48)
202+
case .success(let image):
203+
image
204+
.resizable()
205+
.scaledToFill()
206+
.frame(width: 48, height: 48)
207+
.clipShape(RoundedRectangle(cornerRadius: 10))
208+
.shadow(radius: 1, y: 1)
209+
.onAppear {
210+
ImageCache.shared.set(image, for: iconURL)
211+
}
212+
case .failure:
213+
Image(systemName: "app")
214+
.resizable()
215+
.scaledToFit()
216+
.frame(width: 36, height: 36)
217+
.foregroundColor(.secondary)
218+
@unknown default:
219+
EmptyView()
229220
}
230221
}
231222
} else {
232-
Image(systemName: "app").resizable().scaledToFit()
233-
.frame(width: 36, height: 36).foregroundColor(.secondary)
223+
Image(systemName: "app")
224+
.resizable()
225+
.scaledToFit()
226+
.frame(width: 36, height: 36)
227+
.foregroundColor(.secondary)
234228
}
235229

236230
VStack(alignment: .leading, spacing: 2) {
237-
Text(app.name).font(.headline).lineLimit(1)
231+
Text(app.name)
232+
.font(.headline)
233+
.lineLimit(1)
238234
if let subtitle = app.subtitle, !subtitle.isEmpty {
239-
Text(subtitle).font(.subheadline).foregroundColor(.secondary).lineLimit(1)
235+
Text(subtitle)
236+
.font(.subheadline)
237+
.foregroundColor(.secondary)
238+
.lineLimit(1)
240239
} else if let dev = app.developerName {
241-
Text(dev).font(.subheadline).foregroundColor(.secondary).lineLimit(1)
240+
Text(dev)
241+
.font(.subheadline)
242+
.foregroundColor(.secondary)
243+
.lineLimit(1)
242244
}
243245
}
244246

245247
Spacer()
246248

247249
if let size = app.versions?.first?.size {
248250
Text(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))
249-
.font(.caption2).foregroundColor(.secondary)
251+
.font(.caption2)
252+
.foregroundColor(.secondary)
250253
} else {
251-
Image(systemName: "chevron.right").foregroundColor(.secondary)
254+
Image(systemName: "chevron.right")
255+
.foregroundColor(.secondary)
252256
}
253257
}
254258
.padding(.vertical, 6)

0 commit comments

Comments
 (0)