@@ -345,7 +345,7 @@ struct RetryAsyncImage<Content: View, Placeholder: View, Failure: View>: View {
345345enum SortOption : String , CaseIterable , Identifiable {
346346 case nameAZ = " Name: A - Z "
347347 case nameZA = " Name: Z - A "
348- case repoAZ = " Repository: A - Z "
348+ case repoAZ = " Repository "
349349 case dateNewOld = " Date: New - Old "
350350 case dateOldNew = " Date: Old - New "
351351 case sizeLowHigh = " Size: Low - High "
@@ -397,6 +397,9 @@ public struct AppsView: View {
397397 @State private var selectedApp : AltApp ? = nil
398398 @State private var sortOption : SortOption = . nameAZ
399399
400+ /// Which repositories are expanded (by repository key string). Starts with all repos expanded when apps load.
401+ @State private var expandedRepos : Set < String > = [ ]
402+
400403 public init ( repoURLs: [ URL ] = [ URL ( string: " https://repository.apptesters.org/ " ) !] ) {
401404 _vm = StateObject ( wrappedValue: RepoViewModel ( sourceURLs: repoURLs) )
402405 }
@@ -409,10 +412,14 @@ public struct AppsView: View {
409412 case . nameZA:
410413 return vm. apps. sorted { $0. name. localizedCaseInsensitiveCompare ( $1. name) == . orderedDescending }
411414 case . repoAZ:
415+ // sort primarily by repo name, then app name
412416 return vm. apps. sorted {
413- let a = $0. repositoryName ?? " "
414- let b = $1. repositoryName ?? " "
415- return a. localizedCaseInsensitiveCompare ( b) == . orderedAscending
417+ let aRepo = $0. repositoryName ?? " "
418+ let bRepo = $1. repositoryName ?? " "
419+ if aRepo. localizedCaseInsensitiveCompare ( bRepo) == . orderedSame {
420+ return $0. name. localizedCaseInsensitiveCompare ( $1. name) == . orderedAscending
421+ }
422+ return aRepo. localizedCaseInsensitiveCompare ( bRepo) == . orderedAscending
416423 }
417424 case . dateNewOld:
418425 return vm. apps. sorted {
@@ -441,6 +448,7 @@ public struct AppsView: View {
441448 }
442449 }
443450
451+ // Filtered apps (search)
444452 private var filteredApps : [ AltApp ] {
445453 let query = searchText. trimmingCharacters ( in: . whitespacesAndNewlines)
446454 guard !query. isEmpty else { return sortedApps }
@@ -455,6 +463,31 @@ public struct AppsView: View {
455463 }
456464 }
457465
466+ // Group apps by repository name (fallback to "Unknown Repository")
467+ private var groupedApps : [ String : [ AltApp ] ] {
468+ Dictionary ( grouping: filteredApps, by: { $0. repositoryName ?? " Unknown Repository " } )
469+ }
470+
471+ // Decide an ordered list of repository keys to display. If sorting by repo, keep repo order alphabetical.
472+ private var orderedRepoKeys : [ String ] {
473+ let keys = Array ( groupedApps. keys)
474+ if sortOption == . repoAZ {
475+ return keys. sorted { $0. localizedCaseInsensitiveCompare ( $1) == . orderedAscending }
476+ } else {
477+ // keep stable order: repositories ordered by first occurrence in sortedApps
478+ var order : [ String ] = [ ]
479+ for app in sortedApps {
480+ let key = app. repositoryName ?? " Unknown Repository "
481+ if !order. contains ( key) { order. append ( key) }
482+ }
483+ // include any keys that didn't appear (edge-case)
484+ for k in keys where !order. contains ( k) {
485+ order. append ( k)
486+ }
487+ return order
488+ }
489+ }
490+
458491 public var body : some View {
459492 VStack ( spacing: 0 ) {
460493
@@ -480,10 +513,8 @@ public struct AppsView: View {
480513 . padding ( 10 )
481514 . background ( . regularMaterial)
482515 . cornerRadius ( 10 )
483- // make the search take flexible space but leave room for picker
484516 . frame ( maxWidth: . infinity)
485517
486- // Sort picker next to search bar
487518 Picker ( selection: $sortOption, label: Label ( " Sort " , systemImage: " arrow.up.arrow.down " ) ) {
488519 ForEach ( SortOption . allCases) { option in
489520 Text ( option. rawValue) . tag ( option)
@@ -494,7 +525,7 @@ public struct AppsView: View {
494525 . padding ( . vertical, 8 )
495526 . background ( . regularMaterial)
496527 . cornerRadius ( 10 )
497- . frame ( minWidth: 170 ) // keep the picker reasonably wide
528+ . frame ( minWidth: 170 )
498529 }
499530 . padding ( . horizontal)
500531 . padding ( . top, 8 )
@@ -523,13 +554,67 @@ public struct AppsView: View {
523554 }
524555 . padding ( )
525556 } else {
526- List ( filteredApps) { app in
527- Button {
528- selectedApp = app
529- } label: {
530- AppRowView ( app: app)
557+ // List of grouped repositories
558+ List {
559+ ForEach ( orderedRepoKeys, id: \. self) { repoKey in
560+ // header row
561+ Section {
562+ if expandedRepos. contains ( repoKey) {
563+ // show apps for this repo
564+ ForEach ( groupedApps [ repoKey] ?? [ ] ) { app in
565+ Button {
566+ selectedApp = app
567+ } label: {
568+ AppRowView ( app: app)
569+ }
570+ . buttonStyle ( . plain)
571+ }
572+ } else {
573+ // collapsed: show a compact preview (optional - show first 1 app)
574+ if let first = groupedApps [ repoKey] ? . first {
575+ Button {
576+ selectedApp = first
577+ } label: {
578+ AppRowView ( app: first)
579+ . opacity ( 0.85 )
580+ }
581+ . buttonStyle ( . plain)
582+ }
583+ }
584+ } header: {
585+ // Custom header with toggle
586+ HStack ( spacing: 8 ) {
587+ Button ( action: {
588+ withAnimation ( . spring( ) ) {
589+ if expandedRepos. contains ( repoKey) {
590+ expandedRepos. remove ( repoKey)
591+ } else {
592+ expandedRepos. insert ( repoKey)
593+ }
594+ }
595+ } ) {
596+ HStack ( spacing: 8 ) {
597+ Image ( systemName: expandedRepos. contains ( repoKey) ? " chevron.down " : " chevron.right " )
598+ . font ( . system( size: 14 , weight: . semibold) )
599+ . foregroundColor ( . accentColor)
600+ . frame ( width: 18 , height: 18 )
601+ VStack ( alignment: . leading, spacing: 1 ) {
602+ Text ( repoKey)
603+ . font ( . subheadline)
604+ . fontWeight ( . semibold)
605+ Text ( " \( groupedApps [ repoKey] ? . count ?? 0 ) app \( ( groupedApps [ repoKey] ? . count ?? 0 ) == 1 ? " " : " s " ) " )
606+ . font ( . caption)
607+ . foregroundColor ( . secondary)
608+ }
609+ Spacer ( )
610+ }
611+ }
612+ . buttonStyle ( . plain)
613+ }
614+ . padding ( . vertical, 6 )
615+ . contentShape ( Rectangle ( ) )
616+ }
531617 }
532- . buttonStyle ( . plain)
533618 }
534619 . listStyle ( PlainListStyle ( ) )
535620 . refreshable { vm. refresh ( ) }
@@ -548,6 +633,21 @@ public struct AppsView: View {
548633 . help ( " Refresh repository " )
549634 }
550635 }
636+ // When apps change (or on appear), default to all repos expanded so they start maximised
637+ . onAppear {
638+ expandedRepos = Set ( orderedRepoKeys)
639+ }
640+ . onChange ( of: vm. apps) { _ in
641+ expandedRepos = Set ( orderedRepoKeys)
642+ }
643+ . onChange ( of: searchText) { _ in
644+ // If search modifies the repo list, ensure expansion state includes newly visible repos
645+ expandedRepos. formUnion ( orderedRepoKeys)
646+ }
647+ . onChange ( of: sortOption) { _ in
648+ // keep currently expanded repos where possible, but ensure new repos are included
649+ expandedRepos. formUnion ( orderedRepoKeys)
650+ }
551651 }
552652}
553653
0 commit comments