11import SwiftUI
2- import Combine
32
4- // MARK: - Models (decodable for AltStore source format )
3+ // MARK: - Models (AltStore-ish )
54struct AltSource : Decodable {
65 let name : String ?
76 let subtitle : String ?
@@ -10,16 +9,15 @@ struct AltSource: Decodable {
109}
1110
1211struct AltApp : Decodable , Identifiable {
13- // Use bundleIdentifier as stable id
1412 var id : String { bundleIdentifier }
1513 let name : String
1614 let bundleIdentifier : String
1715 let developerName : String ?
1816 let subtitle : String ?
1917 let iconURL : URL ?
18+ let localizedDescription : String ?
2019 let versions : [ AppVersion ] ?
2120
22- // A convenience computed property for a primary download URL (if present on the latest version)
2321 var latestDownloadURL : URL ? {
2422 versions? . first? . downloadURL
2523 }
@@ -49,110 +47,96 @@ final class RepoViewModel: ObservableObject {
4947 Task { await load ( ) }
5048 }
5149
52- /// Load JSON and decode apps. Tries both `{ "apps": [...] }` and direct `[App]` shapes.
5350 func load( ) async {
5451 isLoading = true
5552 errorMessage = nil
5653 defer { isLoading = false }
5754
5855 do {
5956 var request = URLRequest ( url: sourceURL)
60- // Some repos expect a GET; set a reasonable user-agent
61- request. setValue ( " AppTestersListView/1.0 (iOS) " , forHTTPHeaderField: " User-Agent " )
62-
57+ request. setValue ( " ProStore/1.0 (iOS) " , forHTTPHeaderField: " User-Agent " )
6358 let ( data, response) = try await URLSession . shared. data ( for: request)
6459 if let http = response as? HTTPURLResponse , !( 200 ... 299 ) . contains ( http. statusCode) {
6560 throw NSError ( domain: " RepoFetcher " , code: http. statusCode, userInfo: [ NSLocalizedDescriptionKey: " HTTP \( http. statusCode) " ] )
6661 }
6762
6863 let decoder = JSONDecoder ( )
69- // First try decoding top-level AltSource ( common altstore format)
64+ // try common shapes
7065 if let source = try ? decoder. decode ( AltSource . self, from: data) , let apps = source. apps {
7166 self . apps = apps
7267 return
7368 }
74-
75- // Fallback: some repos publish a raw array of apps
7669 if let appsArray = try ? decoder. decode ( [ AltApp ] . self, from: data) {
7770 self . apps = appsArray
7871 return
7972 }
80-
81- // Another fallback: some repos wrap apps in a top-level dictionary under other keys
82- if let jsonObject = try JSONSerialization . jsonObject ( with: data) as? [ String : Any ] {
83- // try to extract "apps" key and re-decode that fragment
84- if let appsFragment = jsonObject [ " apps " ] {
85- let fragmentData = try JSONSerialization . data ( withJSONObject: appsFragment)
86- let appsArray = try decoder. decode ( [ AltApp ] . self, from: fragmentData)
87- self . apps = appsArray
88- return
89- }
73+ // fallbacks: try extracting "apps" key manually
74+ if let jsonObject = try JSONSerialization . jsonObject ( with: data) as? [ String : Any ] ,
75+ let appsFragment = jsonObject [ " apps " ] {
76+ let fragmentData = try JSONSerialization . data ( withJSONObject: appsFragment)
77+ let appsArray = try decoder. decode ( [ AltApp ] . self, from: fragmentData)
78+ self . apps = appsArray
79+ return
9080 }
91-
92- throw NSError ( domain: " RepoFetcher " , code: - 1 , userInfo: [ NSLocalizedDescriptionKey: " Unexpected JSON format. " ] )
93-
81+ throw NSError ( domain: " RepoFetcher " , code: - 1 , userInfo: [ NSLocalizedDescriptionKey: " Unexpected JSON format " ] )
9482 } catch {
95- self . errorMessage = " Failed to load repository : \( error. localizedDescription) "
83+ self . errorMessage = " Failed to load: \( error. localizedDescription) "
9684 self . apps = [ ]
9785 }
9886 }
9987
100- /// Public method to refresh on demand
10188 func refresh( ) {
10289 Task { await load ( ) }
10390 }
10491}
10592
106- // MARK: - The SwiftUI View
93+ // MARK: - AppsView (no NavigationView)
10794public struct AppsView : View {
10895 @StateObject private var vm : RepoViewModel
10996
110- // Public initializer so you can pass a custom repository URL (defaults to user's provided URL )
97+ /// Provide custom repo URL if you want (defaults to https://repository.apptesters.org/ )
11198 public init ( repoURL: URL = URL ( string: " https://repository.apptesters.org/ " ) !) {
11299 _vm = StateObject ( wrappedValue: RepoViewModel ( sourceURL: repoURL) )
113100 }
114101
115102 public var body : some View {
116- NavigationView {
117- Group {
118- if vm. isLoading && vm. apps. isEmpty {
119- VStack ( spacing: 12 ) {
120- ProgressView ( )
121- Text ( " Loading apps... " )
122- . font ( . subheadline)
123- . foregroundColor ( . secondary)
124- }
125- . padding ( )
126- } else if let error = vm. errorMessage, vm. apps. isEmpty {
127- VStack ( spacing: 12 ) {
128- Text ( " Error " )
129- . font ( . headline)
130- Text ( error)
131- . font ( . subheadline)
132- . foregroundColor ( . secondary)
133- . multilineTextAlignment ( . center)
134- Button ( " Retry " ) { vm. refresh ( ) }
135- . padding ( . top, 8 )
136- }
137- . padding ( )
138- } else {
139- List ( vm. apps) { app in
103+ Group {
104+ if vm. isLoading && vm. apps. isEmpty {
105+ VStack ( spacing: 12 ) {
106+ ProgressView ( )
107+ Text ( " Loading apps... " )
108+ . font ( . subheadline)
109+ . foregroundColor ( . secondary)
110+ }
111+ . padding ( )
112+ } else if let error = vm. errorMessage, vm. apps. isEmpty {
113+ VStack ( spacing: 12 ) {
114+ Text ( " Error " )
115+ . font ( . headline)
116+ Text ( error)
117+ . font ( . subheadline)
118+ . foregroundColor ( . secondary)
119+ . multilineTextAlignment ( . center)
120+ Button ( " Retry " ) { vm. refresh ( ) }
121+ . padding ( . top, 8 )
122+ }
123+ . padding ( )
124+ } else {
125+ List ( vm. apps) { app in
126+ // parent NavigationStack handles navigation; provide a NavigationLink to a detail view
127+ NavigationLink ( value: app) {
140128 AppRowView ( app: app)
141129 }
142- . listStyle ( PlainListStyle ( ) )
143- . refreshable { vm. refresh ( ) } // iOS 15+ pull-to-refresh
144130 }
145- }
146- . navigationTitle ( " AppTesters " )
147- . toolbar {
148- ToolbarItem ( placement: . navigationBarTrailing) {
149- Button ( action: { vm. refresh ( ) } ) {
150- Image ( systemName: " arrow.clockwise " )
151- }
152- . help ( " Refresh repository " )
131+ . listStyle ( . plain)
132+ . refreshable { vm. refresh ( ) } // iOS 15+
133+ // If you don't want navigation links, swap NavigationLink -> Button/openURL as you prefer.
134+ . navigationDestination ( for: AltApp . self) { app in
135+ AppDetailView ( app: app)
153136 }
154137 }
155138 }
139+ // Do not set navigationTitle here — your parent already does that
156140 }
157141}
158142
@@ -162,19 +146,17 @@ private struct AppRowView: View {
162146
163147 var body : some View {
164148 HStack ( spacing: 12 ) {
165- // AsyncImage is available iOS 15+
166149 if let iconURL = app. iconURL {
167150 AsyncImage ( url: iconURL) { phase in
168151 switch phase {
169152 case . empty:
170153 ProgressView ( )
171154 . frame ( width: 48 , height: 48 )
172155 case . success( let image) :
173- image
174- . resizable ( )
156+ image. resizable ( )
175157 . scaledToFill ( )
176158 . frame ( width: 48 , height: 48 )
177- . clipShape ( RoundedRectangle ( cornerRadius: 10 , style: . continuous) )
159+ . clipShape ( RoundedRectangle ( cornerRadius: 8 , style: . continuous) )
178160 . shadow ( radius: 1 , y: 1 )
179161 case . failure:
180162 Image ( systemName: " app " )
@@ -213,7 +195,6 @@ private struct AppRowView: View {
213195
214196 Spacer ( )
215197
216- // optionally show a chevron or small metadata
217198 if let size = app. versions? . first? . size {
218199 Text ( ByteCountFormatter . string ( fromByteCount: Int64 ( size) , countStyle: . file) )
219200 . font ( . caption2)
@@ -225,4 +206,90 @@ private struct AppRowView: View {
225206 }
226207 . padding ( . vertical, 6 )
227208 }
209+ }
210+
211+ // MARK: - Simple Detail View
212+ private struct AppsView : View {
213+ let app : AltApp
214+ @Environment ( \. openURL) private var openURL
215+
216+ var body : some View {
217+ ScrollView {
218+ VStack ( spacing: 16 ) {
219+ if let iconURL = app. iconURL {
220+ AsyncImage ( url: iconURL) { phase in
221+ switch phase {
222+ case . empty:
223+ ProgressView ( )
224+ . frame ( width: 120 , height: 120 )
225+ case . success( let image) :
226+ image. resizable ( )
227+ . scaledToFit ( )
228+ . frame ( width: 120 , height: 120 )
229+ . clipShape ( RoundedRectangle ( cornerRadius: 16 ) )
230+ default :
231+ Image ( systemName: " app " )
232+ . resizable ( )
233+ . scaledToFit ( )
234+ . frame ( width: 100 , height: 100 )
235+ }
236+ }
237+ }
238+
239+ VStack ( alignment: . leading, spacing: 6 ) {
240+ Text ( app. name)
241+ . font ( . title2)
242+ . bold ( )
243+ if let dev = app. developerName {
244+ Text ( dev)
245+ . font ( . subheadline)
246+ . foregroundColor ( . secondary)
247+ }
248+ if let desc = app. localizedDescription {
249+ Text ( desc)
250+ . font ( . body)
251+ . padding ( . top, 6 )
252+ }
253+ }
254+ . frame ( maxWidth: . infinity, alignment: . leading)
255+ . padding ( . horizontal)
256+
257+ if let version = app. versions? . first {
258+ VStack ( alignment: . leading, spacing: 6 ) {
259+ Text ( " Latest " )
260+ . font ( . headline)
261+ HStack {
262+ VStack ( alignment: . leading) {
263+ if let v = version. version { Text ( " Version: \( v) " ) }
264+ if let b = version. buildVersion { Text ( " Build: \( b) " ) }
265+ if let min = version. minOSVersion { Text ( " Min iOS: \( min) " ) }
266+ if let size = version. size {
267+ Text ( " Size: \( ByteCountFormatter . string ( fromByteCount: Int64 ( size) , countStyle: . file) ) " )
268+ }
269+ }
270+ Spacer ( )
271+ }
272+ }
273+ . padding ( )
274+ . background ( . thinMaterial)
275+ . clipShape ( RoundedRectangle ( cornerRadius: 12 ) )
276+ . padding ( . horizontal)
277+ }
278+
279+ if let url = app. latestDownloadURL {
280+ Button ( action: { openURL ( url) } ) {
281+ Label ( " Open download URL " , systemImage: " arrow.down.doc " )
282+ . frame ( maxWidth: . infinity)
283+ }
284+ . buttonStyle ( . borderedProminent)
285+ . padding ( . horizontal)
286+ }
287+
288+ Spacer ( minLength: 20 )
289+ }
290+ . padding ( . top)
291+ }
292+ . navigationTitle ( app. name)
293+ . navigationBarTitleDisplayMode ( . inline)
294+ }
228295}
0 commit comments