@@ -376,7 +376,7 @@ fileprivate func appDate(for app: AltApp) -> Date? {
376376 if let vd = app. versionDate {
377377 let formatter = ISO8601DateFormatter ( )
378378 // attempt strict "yyyy-MM-dd"
379- if let date = ISO8601DateFormatter ( ) . date ( from: vd + " T00:00:00Z " ) {
379+ if let date = formatter . date ( from: vd + " T00:00:00Z " ) {
380380 return date
381381 }
382382 // fallback try DateFormatter
@@ -404,15 +404,15 @@ public struct AppsView: View {
404404 _vm = StateObject ( wrappedValue: RepoViewModel ( sourceURLs: repoURLs) )
405405 }
406406
407- // Sort the vm.apps according to the selected sort option
407+ // MARK: - Smaller computed helpers to keep `body` tiny
408+
408409 private var sortedApps : [ AltApp ] {
409410 switch sortOption {
410411 case . nameAZ:
411412 return vm. apps. sorted { $0. name. localizedCaseInsensitiveCompare ( $1. name) == . orderedAscending }
412413 case . nameZA:
413414 return vm. apps. sorted { $0. name. localizedCaseInsensitiveCompare ( $1. name) == . orderedDescending }
414415 case . repoAZ:
415- // sort primarily by repo name, then app name
416416 return vm. apps. sorted {
417417 let aRepo = $0. repositoryName ?? " "
418418 let bRepo = $1. repositoryName ?? " "
@@ -448,7 +448,6 @@ public struct AppsView: View {
448448 }
449449 }
450450
451- // Filtered apps (search)
452451 private var filteredApps : [ AltApp ] {
453452 let query = searchText. trimmingCharacters ( in: . whitespacesAndNewlines)
454453 guard !query. isEmpty else { return sortedApps }
@@ -463,161 +462,167 @@ public struct AppsView: View {
463462 }
464463 }
465464
466- // Group apps by repository name (fallback to "Unknown Repository")
467465 private var groupedApps : [ String : [ AltApp ] ] {
468466 Dictionary ( grouping: filteredApps, by: { $0. repositoryName ?? " Unknown Repository " } )
469467 }
470468
471- // Decide an ordered list of repository keys to display. If sorting by repo, keep repo order alphabetical.
472469 private var orderedRepoKeys : [ String ] {
473470 let keys = Array ( groupedApps. keys)
474471 if sortOption == . repoAZ {
475472 return keys. sorted { $0. localizedCaseInsensitiveCompare ( $1) == . orderedAscending }
476473 } else {
477- // keep stable order: repositories ordered by first occurrence in sortedApps
478474 var order : [ String ] = [ ]
479475 for app in sortedApps {
480476 let key = app. repositoryName ?? " Unknown Repository "
481477 if !order. contains ( key) { order. append ( key) }
482478 }
483- // include any keys that didn't appear (edge-case)
484479 for k in keys where !order. contains ( k) {
485480 order. append ( k)
486481 }
487482 return order
488483 }
489484 }
490485
491- public var body : some View {
492- VStack ( spacing: 0 ) {
486+ // MARK: - View pieces
493487
494- // Search + Sort Bar (hidden during initial load)
495- if !( vm. isLoading && vm. apps. isEmpty) {
496- HStack ( spacing: 8 ) {
497- HStack {
498- Image ( systemName: " magnifyingglass " )
499- . foregroundColor ( . secondary)
500- TextField ( " Search apps, developer or bundle ID " , text: $searchText)
501- . textInputAutocapitalization ( . never)
502- . disableAutocorrection ( true )
503- . focused ( $searchFieldFocused)
504- . submitLabel ( . search)
505- if !searchText. isEmpty {
506- Button ( action: { searchText = " " } ) {
507- Image ( systemName: " xmark.circle.fill " )
508- . foregroundColor ( . secondary)
509- }
510- . buttonStyle ( . plain)
488+ @ViewBuilder private var searchAndSortBar : some View {
489+ if !( vm. isLoading && vm. apps. isEmpty) {
490+ HStack ( spacing: 8 ) {
491+ HStack {
492+ Image ( systemName: " magnifyingglass " )
493+ . foregroundColor ( . secondary)
494+ TextField ( " Search apps, developer or bundle ID " , text: $searchText)
495+ . textInputAutocapitalization ( . never)
496+ . disableAutocorrection ( true )
497+ . focused ( $searchFieldFocused)
498+ . submitLabel ( . search)
499+ if !searchText. isEmpty {
500+ Button ( action: { searchText = " " } ) {
501+ Image ( systemName: " xmark.circle.fill " )
502+ . foregroundColor ( . secondary)
511503 }
504+ . buttonStyle ( . plain)
512505 }
513- . padding ( 10 )
514- . background ( . regularMaterial )
515- . cornerRadius ( 10 )
516- . frame ( maxWidth : . infinity )
517-
518- Picker ( selection : $sortOption , label : Label ( " Sort " , systemImage : " arrow.up.arrow.down " ) ) {
519- ForEach ( SortOption . allCases ) { option in
520- Text ( option . rawValue ) . tag ( option)
521- }
506+ }
507+ . padding ( 10 )
508+ . background ( . regularMaterial )
509+ . cornerRadius ( 10 )
510+ . frame ( maxWidth : . infinity )
511+
512+ Picker ( selection : $sortOption , label : Label ( " Sort " , systemImage : " arrow.up.arrow.down " ) ) {
513+ ForEach ( SortOption . allCases ) { option in
514+ Text ( option . rawValue ) . tag ( option )
522515 }
523- . pickerStyle ( MenuPickerStyle ( ) )
524- . padding ( . horizontal, 10 )
525- . padding ( . vertical, 8 )
526- . background ( . regularMaterial)
527- . cornerRadius ( 10 )
528- . frame ( minWidth: 170 )
529516 }
530- . padding ( . horizontal)
531- . padding ( . top, 8 )
517+ . pickerStyle ( MenuPickerStyle ( ) )
518+ . padding ( . horizontal, 10 )
519+ . padding ( . vertical, 8 )
520+ . background ( . regularMaterial)
521+ . cornerRadius ( 10 )
522+ . frame ( minWidth: 170 )
532523 }
524+ . padding ( . horizontal)
525+ . padding ( . top, 8 )
526+ }
527+ }
533528
534- // Content
535- Group {
536- if vm. isLoading && vm. apps. isEmpty {
537- VStack ( spacing: 12 ) {
538- ProgressView ( )
539- Text ( " Loading apps... " )
540- . font ( . subheadline)
541- . foregroundColor ( . secondary)
529+ @ViewBuilder private var loadingView : some View {
530+ VStack ( spacing: 12 ) {
531+ ProgressView ( )
532+ Text ( " Loading apps... " )
533+ . font ( . subheadline)
534+ . foregroundColor ( . secondary)
535+ }
536+ . padding ( )
537+ }
538+
539+ @ViewBuilder private func errorView( _ error: String ) -> some View {
540+ VStack ( spacing: 12 ) {
541+ Text ( " Error " )
542+ . font ( . headline)
543+ Text ( error)
544+ . font ( . subheadline)
545+ . foregroundColor ( . secondary)
546+ . multilineTextAlignment ( . center)
547+ Button ( " Retry " ) { vm. refresh ( ) }
548+ . padding ( . top, 8 )
549+ }
550+ . padding ( )
551+ }
552+
553+ @ViewBuilder private func repoHeader( _ repoKey: String ) -> some View {
554+ HStack ( spacing: 8 ) {
555+ Button ( action: {
556+ withAnimation ( . spring( ) ) {
557+ if expandedRepos. contains ( repoKey) {
558+ expandedRepos. remove ( repoKey)
559+ } else {
560+ expandedRepos. insert ( repoKey)
542561 }
543- . padding ( )
544- } else if let error = vm. errorMessage, vm. apps. isEmpty {
545- VStack ( spacing: 12 ) {
546- Text ( " Error " )
547- . font ( . headline)
548- Text ( error)
562+ }
563+ } ) {
564+ HStack ( spacing: 8 ) {
565+ Image ( systemName: expandedRepos. contains ( repoKey) ? " chevron.down " : " chevron.right " )
566+ . font ( . system( size: 14 , weight: . semibold) )
567+ . foregroundColor ( . accentColor)
568+ . frame ( width: 18 , height: 18 )
569+ VStack ( alignment: . leading, spacing: 1 ) {
570+ Text ( repoKey)
549571 . font ( . subheadline)
572+ . fontWeight ( . semibold)
573+ Text ( " \( groupedApps [ repoKey] ? . count ?? 0 ) app \( ( groupedApps [ repoKey] ? . count ?? 0 ) == 1 ? " " : " s " ) " )
574+ . font ( . caption)
550575 . foregroundColor ( . secondary)
551- . multilineTextAlignment ( . center)
552- Button ( " Retry " ) { vm. refresh ( ) }
553- . padding ( . top, 8 )
554576 }
555- . padding ( )
577+ Spacer ( )
578+ }
579+ }
580+ . buttonStyle ( . plain)
581+ }
582+ . padding ( . vertical, 6 )
583+ . contentShape ( Rectangle ( ) )
584+ }
585+
586+ @ViewBuilder private func repoSection( _ repoKey: String ) -> some View {
587+ Section {
588+ if expandedRepos. contains ( repoKey) {
589+ ForEach ( groupedApps [ repoKey] ?? [ ] ) { app in
590+ Button { selectedApp = app } label: { AppRowView ( app: app) }
591+ . buttonStyle ( . plain)
592+ }
593+ } else {
594+ if let first = groupedApps [ repoKey] ? . first {
595+ Button { selectedApp = first } label: { AppRowView ( app: first) . opacity ( 0.85 ) }
596+ . buttonStyle ( . plain)
597+ }
598+ }
599+ } header: {
600+ repoHeader ( repoKey)
601+ }
602+ }
603+
604+ @ViewBuilder private var listView : some View {
605+ List {
606+ ForEach ( orderedRepoKeys, id: \. self) { repoKey in
607+ repoSection ( repoKey)
608+ }
609+ }
610+ . listStyle ( PlainListStyle ( ) )
611+ . refreshable { vm. refresh ( ) }
612+ }
613+
614+ // MARK: - Body stays small and easy to type-check
615+ public var body : some View {
616+ VStack ( spacing: 0 ) {
617+ searchAndSortBar
618+
619+ Group {
620+ if vm. isLoading && vm. apps. isEmpty {
621+ loadingView
622+ } else if let error = vm. errorMessage, vm. apps. isEmpty {
623+ errorView ( error)
556624 } else {
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- }
617- }
618- }
619- . listStyle ( PlainListStyle ( ) )
620- . refreshable { vm. refresh ( ) }
625+ listView
621626 }
622627 }
623628 . padding ( . top, 8 )
@@ -626,26 +631,32 @@ public struct AppsView: View {
626631 AppDetailView ( app: app)
627632 }
628633 . toolbar {
629- ToolbarItem ( placement: . navigationBarTrailing) {
634+ ToolbarItemGroup ( placement: . navigationBarTrailing) {
630635 Button ( action: { vm. refresh ( ) } ) {
631636 Image ( systemName: " arrow.clockwise " )
632637 }
633- . help ( " Refresh repository " )
638+ Button ( action: {
639+ withAnimation ( . spring( ) ) { expandedRepos = Set ( orderedRepoKeys) }
640+ } ) {
641+ Image ( systemName: " rectangle.expand.vertical " )
642+ }
643+ Button ( action: {
644+ withAnimation ( . spring( ) ) { expandedRepos. removeAll ( ) }
645+ } ) {
646+ Image ( systemName: " rectangle.compress.vertical " )
647+ }
634648 }
635649 }
636- // When apps change (or on appear), default to all repos expanded so they start maximised
637650 . onAppear {
638651 expandedRepos = Set ( orderedRepoKeys)
639652 }
640653 . onChange ( of: vm. apps) { _ in
641654 expandedRepos = Set ( orderedRepoKeys)
642655 }
643656 . onChange ( of: searchText) { _ in
644- // If search modifies the repo list, ensure expansion state includes newly visible repos
645657 expandedRepos. formUnion ( orderedRepoKeys)
646658 }
647659 . onChange ( of: sortOption) { _ in
648- // keep currently expanded repos where possible, but ensure new repos are included
649660 expandedRepos. formUnion ( orderedRepoKeys)
650661 }
651662 }
0 commit comments