@@ -395,6 +395,132 @@ final class RepoViewModel: ObservableObject {
395395 Task { await loadAllSources ( ) }
396396 }
397397}
398+ // MARK: - RetryAsyncImage (stable layout + retry)
399+ struct RetryAsyncImage < Content: View , Placeholder: View , Failure: View > : View {
400+ let url : URL ?
401+ let maxAttempts : Int
402+ let size : CGSize ?
403+ let content : ( Image ) -> Content
404+ let placeholder : ( ) -> Placeholder
405+ let failure : ( ) -> Failure
406+
407+ @State private var currentAttempt : Int = 0
408+ @State private var retryTrigger : UUID = UUID ( )
409+
410+ private var modifiedURL : URL ? {
411+ guard let url = url else { return nil }
412+ var components = URLComponents ( url: url, resolvingAgainstBaseURL: false )
413+ var query = components? . queryItems ?? [ ]
414+ query. removeAll ( where: { $0. name == " retryAttempt " } )
415+ query. append ( URLQueryItem ( name: " retryAttempt " , value: " \( currentAttempt) " ) )
416+ components? . queryItems = query
417+ return components? . url
418+ }
419+
420+ init (
421+ url: URL ? ,
422+ size: CGSize ? = nil ,
423+ maxAttempts: Int = 3 ,
424+ @ViewBuilder content: @escaping ( Image ) -> Content ,
425+ @ViewBuilder placeholder: @escaping ( ) -> Placeholder ,
426+ @ViewBuilder failure: @escaping ( ) -> Failure
427+ ) {
428+ self . url = url
429+ self . size = size
430+ self . maxAttempts = maxAttempts
431+ self . content = content
432+ self . placeholder = placeholder
433+ self . failure = failure
434+ }
435+
436+ var body : some View {
437+ let frameView = Group {
438+ if let modifiedURL = modifiedURL {
439+ AsyncImage ( url: modifiedURL) { phase in
440+ switch phase {
441+ case . empty:
442+ placeholder ( )
443+ case . success( let image) :
444+ content ( image)
445+ case . failure:
446+ if currentAttempt < maxAttempts - 1 {
447+ placeholder ( )
448+ . task {
449+ try ? await Task . sleep ( nanoseconds: 250_000_000 )
450+ await MainActor . run {
451+ currentAttempt += 1
452+ retryTrigger = UUID ( )
453+ }
454+ }
455+ } else {
456+ failure ( )
457+ }
458+ @unknown default :
459+ placeholder ( )
460+ }
461+ }
462+ } else {
463+ failure ( )
464+ }
465+ }
466+
467+ if let size = size {
468+ frameView
469+ . frame ( width: size. width, height: size. height)
470+ . clipped ( )
471+ } else {
472+ frameView
473+ }
474+ }
475+ }
476+
477+ // Helper for parsing dates
478+ fileprivate func appDate( for app: AltApp ) -> Date ? {
479+ // Prefer fullDate (format like "20251126100919"), else try versionDate like "2025-11-26"
480+ if let full = app. fullDate {
481+ // try parse yyyyMMddHHmmss or yyyyMMdd
482+ let len = full. count
483+ let formatter = DateFormatter ( )
484+ formatter. locale = Locale ( identifier: " en_US_POSIX " )
485+ if len >= 14 {
486+ formatter. dateFormat = " yyyyMMddHHmmss "
487+ } else if len == 8 {
488+ formatter. dateFormat = " yyyyMMdd "
489+ } else {
490+ // fallback attempt
491+ formatter. dateFormat = " yyyyMMddHHmmss "
492+ }
493+ if let d = formatter. date ( from: full) { return d }
494+ }
495+
496+ if let vd = app. versionDate {
497+ let formatter = ISO8601DateFormatter ( )
498+ // attempt strict "yyyy-MM-dd"
499+ if let date = formatter. date ( from: vd + " T00:00:00Z " ) {
500+ return date
501+ }
502+ // fallback try DateFormatter
503+ let df = DateFormatter ( )
504+ df. locale = Locale ( identifier: " en_US_POSIX " )
505+ df. dateFormat = " yyyy-MM-dd "
506+ if let d = df. date ( from: vd) { return d }
507+ }
508+
509+ return nil
510+ }
511+
512+ // MARK: - Sorting
513+ enum SortOption : String , CaseIterable , Identifiable {
514+ case nameAZ = " Name: A - Z "
515+ case nameZA = " Name: Z - A "
516+ case repoAZ = " Repository "
517+ case dateNewOld = " Date: New - Old "
518+ case dateOldNew = " Date: Old - New "
519+ case sizeLowHigh = " Size: Low - High "
520+ case sizeHighLow = " Size: High - Low "
521+
522+ var id : String { self . rawValue }
523+ }
398524
399525// MARK: - AppsView (updated to use cached + vm search/sort)
400526public struct AppsView : View {
0 commit comments