diff --git a/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift b/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift index 1d605fa0..5d473e84 100644 --- a/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift +++ b/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift @@ -3,38 +3,106 @@ import XcodesKit struct InstallationStepRowView: View { let installationStep: XcodeInstallationStep - let highlighted: Bool let cancel: () -> Void - + var body: some View { - HStack { + Button(action: cancel) { switch installationStep { case let .downloading(progress): // FB8955769 ProgressView.init(_: Progress) doesn't ensure that changes from the Progress object are applied to the UI on the main thread // This Progress is vended by URLSession so I don't think we can control that. // Use our own version of ProgressView that does this instead. - ObservingProgressIndicator( - progress, - controlSize: .small, - style: .spinning - ) + DownloadProgressRing(progress, installationStep: installationStep) case .authenticating, .unarchiving, .moving, .trashingArchive, .checkingSecurity, .finishing: - ProgressView() - .scaleEffect(0.5) + InstallProgressRing(fractionCompleted: nil) + .help(installationStep.stepDescription()) + } + } + .buttonStyle(PlainButtonStyle()) + .accessibilityLabel(Text("StopInstallation")) + .accessibilityValue(Text(installationStep.stepDescription())) + .frame(minWidth: 65) + } +} + +private struct DownloadProgressRing: View { + let installationStep: XcodeInstallationStep + @StateObject private var progress: ObservingProgressIndicator.ProgressWrapper + + init(_ progress: Progress, installationStep: XcodeInstallationStep) { + _progress = StateObject(wrappedValue: ObservingProgressIndicator.ProgressWrapper(progress: progress)) + self.installationStep = installationStep + } + + var body: some View { + InstallProgressRing( + fractionCompleted: progress.progress.isIndeterminate ? nil : progress.progress.fractionCompleted + ) + .help(helpText) + } + + private var helpText: String { + let percent = progress.progress.fractionCompleted.formatted(.percent.precision(.fractionLength(0))) + let stepDescription = installationStep.stepDescription(message: "\(installationStep.message) (\(percent))") + let details = progress.progress.xcodesLocalizedDescription + + return details.isEmpty ? stepDescription : "\(stepDescription)\n\(details)" + } +} + +struct InstallProgressRing: View { + @SwiftUI.Environment(\.backgroundProminence) private var backgroundProminence + let fractionCompleted: Double? + var showsStopGlyph = true + + private let lineWidth: CGFloat = 2 + + var body: some View { + ZStack { + ring + .stroke(trackColor, lineWidth: lineWidth) + + if let fractionCompleted { + ring + .trim(from: 0, to: fractionCompleted) + .stroke(tintColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut, value: fractionCompleted) + } else { + TimelineView(.animation) { context in + ring + .trim(from: 0, to: 0.3) + .stroke(tintColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1) * 360)) + } } - - Text(String(format: localizeString("InstallationStepDescription"), installationStep.stepNumber, installationStep.stepCount, installationStep.message)) - .font(.footnote) - - Button(action: cancel) { - Label("Cancel", systemImage: "xmark.circle.fill") - .labelStyle(IconOnlyLabelStyle()) + + if showsStopGlyph { + RoundedRectangle(cornerRadius: 1.5) + .fill(tintColor) + .frame(width: 6, height: 6) } - .buttonStyle(PlainButtonStyle()) - .foregroundColor(highlighted ? .white : .secondary) - .help("StopInstallation") } - .frame(minWidth: 80) + .frame(width: 20, height: 20) + .contentShape(Circle()) + } + + private var ring: some Shape { + Circle().inset(by: lineWidth / 2) + } + + private var tintColor: Color { + backgroundProminence == .increased ? .white : .accentColor + } + + private var trackColor: Color { + backgroundProminence == .increased ? .white.opacity(0.35) : .secondary.opacity(0.3) + } +} + +private extension XcodeInstallationStep { + func stepDescription(message: String? = nil) -> String { + String(format: localizeString("InstallationStepDescription"), stepNumber, stepCount, message ?? self.message) } } @@ -47,37 +115,31 @@ struct InstallView_Previews: PreviewProvider { installationStep: .downloading( progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 } ), - highlighted: false, cancel: {} ) - + InstallationStepRowView( installationStep: .unarchiving, - highlighted: false, cancel: {} ) - + InstallationStepRowView( installationStep: .moving(destination: "/Applications"), - highlighted: false, cancel: {} ) - + InstallationStepRowView( installationStep: .trashingArchive, - highlighted: false, cancel: {} ) - + InstallationStepRowView( installationStep: .checkingSecurity, - highlighted: false, cancel: {} ) - + InstallationStepRowView( installationStep: .finishing, - highlighted: false, cancel: {} ) } @@ -85,19 +147,19 @@ struct InstallView_Previews: PreviewProvider { .background(Color(.windowBackgroundColor)) .environment(\.colorScheme, colorScheme) } - + ForEach(ColorScheme.allCases, id: \.self) { colorScheme in Group { InstallationStepRowView( installationStep: .downloading( progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 } ), - highlighted: true, cancel: {} ) } .padding() .background(Color(.selectedContentBackgroundColor)) + .environment(\.backgroundProminence, .increased) .environment(\.colorScheme, colorScheme) } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 12cfdb05..3a73efad 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -327,7 +327,6 @@ private struct XcodeVersionGroupRow: View { case let .installing(installationStep) = installingVersion.installState { InstallationStepRowView( installationStep: installationStep, - highlighted: false, cancel: { appState.presentedAlert = .cancelInstall(xcode: installingVersion) } ) } else if let latestRelease { diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 2d86e634..35ad509a 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -43,11 +43,21 @@ struct XcodeListViewRow: View { } } - if case let .installed(path) = xcode.installState { - Text(verbatim: path.string) - .font(.caption) - .foregroundColor(.secondary) + Group { + switch xcode.installState { + case let .installed(path): + Text(verbatim: path.string) + case let .installing(installationStep): + Text(installationStep.message) + case .uninstalling: + Text("Uninstalling") + case .notInstalled: + EmptyView() + } } + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) } Spacer() @@ -169,17 +179,12 @@ struct XcodeListViewRow: View { case let .installing(installationStep): InstallationStepRowView( installationStep: installationStep, - highlighted: selected, cancel: { appState.presentedAlert = .cancelInstall(xcode: xcode) } ) case .uninstalling: - HStack(spacing: 4) { - ProgressView() - .scaleEffect(0.5) - Text("Uninstalling") - .font(.caption) - .foregroundStyle(.secondary) - } + InstallProgressRing(fractionCompleted: nil, showsStopGlyph: false) + .frame(minWidth: 65) + .help("Uninstalling") } }