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

Commit d450194

Browse files
authored
Add retractable repos when sort by repository
1 parent 66bc968 commit d450194

File tree

1 file changed

+113
-13
lines changed

1 file changed

+113
-13
lines changed

Sources/prostore/views/AppsView.swift

Lines changed: 113 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ struct RetryAsyncImage<Content: View, Placeholder: View, Failure: View>: View {
345345
enum 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

Comments
 (0)