@@ -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
4638final 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
13398public 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
202191private 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