diff options
Diffstat (limited to 'Hotline/macOS')
| -rw-r--r-- | Hotline/macOS/AboutView.swift | 339 | ||||
| -rw-r--r-- | Hotline/macOS/AppUpdateView.swift | 187 |
2 files changed, 333 insertions, 193 deletions
diff --git a/Hotline/macOS/AboutView.swift b/Hotline/macOS/AboutView.swift index baafcf8..57f855e 100644 --- a/Hotline/macOS/AboutView.swift +++ b/Hotline/macOS/AboutView.swift @@ -1,11 +1,5 @@ import SwiftUI - -enum VersionCheckState { - case needToCheck - case checking - case upToDate - case updateAvailable(version: String) -} +import SwiftUIIntrospect struct AboutContributor: Identifiable { let id: UUID = UUID() @@ -14,164 +8,66 @@ struct AboutContributor: Identifiable { let pictureURL: URL? } -struct AboutView: View { +struct AboutContributorView: View { @Environment(\.openURL) private var openURL - @State private var versionCheck: VersionCheckState = .needToCheck - @State private var downloadURL: String = "https://github.com/mierau/hotline/releases/latest" - @State private var contributors: [AboutContributor] = [] + let contributor: AboutContributor var body: some View { - HStack(alignment: .center, spacing: 0) { - VStack(alignment: .center, spacing: 0) { - Spacer() - - Image("About Hotline") - .padding(.top, 44) - - Text("Hotline") - .font(.system(size: 28)) - .fontWeight(.bold) - .padding(.top, 12) - .kerning(-1.0) - .foregroundColor(.white) - - let appDetails = getAppVersionAndBuild() - Text("Version \(String(format: "%.1f", appDetails.version))b\(appDetails.build)") - .foregroundColor(.white) - .opacity(0.4) - - HStack(alignment: .center) { - switch versionCheck { - case .needToCheck: - Button("Check for Updates") { - Task { - await checkForUpdate() - } - } - .controlSize(.small) - case .checking: - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Checking for updates...") - .fontWeight(.semibold) - } - .foregroundStyle(.white) - .tint(.white) - case .upToDate: - Label("Hotline is up to date.", systemImage: "checkmark.circle.fill") - .foregroundStyle(.white) - .fontWeight(.semibold) - .tint(.white) - .onTapGesture { - versionCheck = .needToCheck - } - case .updateAvailable(let version): - Button("Download Latest \(version)") { - if let url = URL(string: downloadURL) { - openURL(url) - } - } - .controlSize(.small) + HStack { + if let pictureURL = contributor.pictureURL { + AsyncImage(url: pictureURL) { phase in + if let image = phase.image { + image + .interpolation(.high) + .resizable() + .scaledToFit() + .background(.white) + .frame(width: 32, height: 32) + } else if phase.error != nil { + Color.clear + .frame(width: 32, height: 32) + } else { + Color.white + .opacity(0.2) + .frame(width: 32, height: 32) } } - .frame(height: 40) - - Spacer() + .frame(width: 32, height: 32) + .clipShape(Circle()) } - .frame(width: 250) - Spacer() - - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 16) { - - VStack(alignment: .leading, spacing: 4) { - Link(destination: URL(string: "https://github.com/mierau/hotline")!) { - HStack(alignment: .center, spacing: 4) { - Text("Contributors") - .lineLimit(1) - .font(.system(size: 16)) - .fontWeight(.semibold) - .foregroundStyle(.black) - .opacity(0.75) + VStack(alignment: .leading, spacing: 2) { + Text(contributor.username) + .fontWeight(.semibold) + .foregroundStyle(.white) + .lineLimit(1) + + Text(contributor.webURL.absoluteString) + .lineLimit(1) + .truncationMode(.middle) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.4)) + } + } +// } +// .accessibilityAddTraits(.isLink) +// .pointerStyle(.link) + } +} - Image(systemName: "arrow.forward.circle.fill") - .resizable() - .fontWeight(.bold) - .scaledToFit() - .frame(width: 12, height: 12) - .foregroundStyle(.black) - .opacity(0.75) - } - } - .padding(.top, 24) - - Text("Hotline is an open source project made possible by its contributors.") - .font(.system(size: 11)) - .foregroundStyle(.black) - .blendMode(.overlay) - .padding(.trailing, 32) - } - .padding(.bottom, 8) - - ForEach(contributors) { contributor in - Link(destination: contributor.webURL) { - HStack { - if let pictureURL = contributor.pictureURL { - AsyncImage(url: pictureURL) { phase in - if let image = phase.image { - image - .interpolation(.high) - .resizable() - .scaledToFit() - .background(.white) - .frame(width: 32, height: 32) - } else if phase.error != nil { - Color.clear - .frame(width: 32, height: 32) - } else { - Color.white - .opacity(0.2) - .frame(width: 32, height: 32) - } - } - .frame(width: 32, height: 32) - .clipShape(Circle()) - -// AsyncImage(url: pictureURL) { img in -// img -// .interpolation(.high) -// .resizable() -// .scaledToFit() -// .background(.white) -// } placeholder: { -// Color.white.opacity(0.2) -// .frame(width: 32, height: 32) -// } -// .frame(width: 32, height: 32) -// .clipShape(Circle()) - } - - VStack(alignment: .leading, spacing: 2) { - Text(contributor.username) - .fontWeight(.semibold) - .foregroundStyle(.white) - .lineLimit(1) - - Text(contributor.webURL.absoluteString) - .lineLimit(1) - .truncationMode(.middle) - .font(.system(size: 11)) - .foregroundStyle(.white.opacity(0.4)) - } - } - } - } +struct AboutView: View { + @Environment(\.openURL) private var openURL + + @State private var contributors: [AboutContributor] = [] + + var body: some View { + HStack(alignment: .center, spacing: 0) { + self.brandView + self.contributorsList + .background { + Color.black.blendMode(.softLight).opacity(0.3).ignoresSafeArea() } - } - .scrollClipDisabled() } .frame(width: 570, height: 330) .background( @@ -184,12 +80,105 @@ struct AboutView: View { .offset(x: 250) } ) - .background(Color.hotlineRed) .task { await loadContributors() } } + private var brandView: some View { + VStack(alignment: .center, spacing: 4) { + Spacer() + + Image("About Hotline") + + Text("Hotline") + .font(.system(size: 28)) + .fontWeight(.bold) + .padding(.top, 12) + .kerning(-1.0) + .foregroundColor(.white) + + let appDetails = getAppVersionAndBuild() + Button { + self.openURL(URL(string: "https://github.com/mierau/hotline/releases/tag/\(appDetails.version)beta\(appDetails.build)")!) + } label: { + + Text("Version \(String(format: "%.1f", appDetails.version))b\(appDetails.build)") + .foregroundColor(.white.opacity(0.756)) + .padding(.vertical, 4) + .padding(.horizontal, 12) + .background { + Capsule() + .fill(.white.opacity(0.5)) + .blendMode(.softLight) + } + } + .buttonStyle(.plain) + .buttonBorderShape(.capsule) + .padding(.bottom, 16) + + Spacer() + } + .frame(width: 250) + } + + private var contributorsList: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 16) { + self.contributorHeaderView + ForEach(self.contributors) { contributor in + Button { + self.openURL(contributor.webURL) + } label: { + AboutContributorView(contributor: contributor) + } + .buttonStyle(.plain) + .accessibilityAddTraits(.isLink) + .pointerStyle(.link) + } + } + .frame(maxWidth: .infinity) + .padding() + } + .scrollClipDisabled() + .scrollContentBackground(.hidden) +// .introspect(.scrollView, on: .macOS(.v10_15, .v11, .v12, .v13, .v14, .v15, .v26)) { v in +// v.automaticallyAdjustsContentInsets = false +// } + } + + private var contributorHeaderView: some View { + VStack(alignment: .leading, spacing: 4) { + Link(destination: URL(string: "https://github.com/mierau/hotline")!) { + HStack(alignment: .center, spacing: 4) { + Text("Contributors") + .lineLimit(1) + .font(.system(size: 16)) + .fontWeight(.semibold) + .foregroundStyle(.black) + .opacity(0.8) + + Image(systemName: "arrow.forward.circle.fill") + .resizable() + .fontWeight(.bold) + .scaledToFit() + .frame(width: 12, height: 12) + .foregroundStyle(.black) + .opacity(0.4) + } + } + .accessibilityAddTraits(.isLink) + .pointerStyle(.link) + + Text("Hotline is an open source project made possible by its contributors.") + .font(.system(size: 11)) + .foregroundStyle(.black) + .opacity(0.5) + .padding(.trailing, 32) + } + .padding(.bottom, 8) + } + func loadContributors() async { var newContributors: [AboutContributor] = [] @@ -214,43 +203,7 @@ struct AboutView: View { contributors = newContributors } } - - func checkForUpdate() async { - let appDetails = getAppVersionAndBuild() - - self.versionCheck = .checking - - do { - let url = URL(string: "https://api.github.com/repos/mierau/hotline/releases/latest")! - let (data, _) = try await URLSession.shared.data(from: url) - let versionExpression = /^([0-9\.]+)beta([0-9]+)/ - - if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let tagName = json["tag_name"] as? String, - let assets = json["assets"] as? [[String: Any]], - let firstAsset = assets.first, - let assetDownloadURL = firstAsset["browser_download_url"] as? String, - let versionMatches = try? versionExpression.wholeMatch(in: tagName) { - if let versionNumber = Double(versionMatches.1), - let buildNumber = Int(versionMatches.2), - versionNumber > appDetails.version || buildNumber > appDetails.build { - let versionString = "\(versionMatches.1)b\(versionMatches.2)" - self.versionCheck = .updateAvailable(version: versionString) - downloadURL = assetDownloadURL - } - else { - self.versionCheck = .upToDate - } - } - else { - self.versionCheck = .needToCheck - } - } - catch { - self.versionCheck = .needToCheck - } - } - + func getAppVersionAndBuild() -> (version: Double, build: Int) { let infoDictionary = Bundle.main.infoDictionary! let version = Double(infoDictionary["CFBundleShortVersionString"]! as! String)! diff --git a/Hotline/macOS/AppUpdateView.swift b/Hotline/macOS/AppUpdateView.swift new file mode 100644 index 0000000..89312fc --- /dev/null +++ b/Hotline/macOS/AppUpdateView.swift @@ -0,0 +1,187 @@ +import SwiftUI +import MarkdownUI +import AppKit +import Observation + +struct AppUpdateView: View { + @Environment(\.dismiss) private var dismiss + @Bindable private var update = AppUpdate.shared + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + if let message = update.message { + messageOnlyView(message) + } + else if update.release != nil { + headerSection + releaseNotesSection + actionRow + } + else { + defaultPlaceholder + } + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + .padding(.top, 8) + .frame(width: update.message != nil ? 380 : 520) + .frame(idealHeight: 360) + .onChange(of: update.showWindow) { _, show in + if !show { + dismiss() + } + } + .onDisappear { + update.handleWindowDismissed() + } + } + + private var headerSection: some View { + HStack(alignment: .center, spacing: 8) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .scaledToFit() + .frame(width: 56, height: 56) + .shadow(color: Color.black.mix(with: .red, by: 0.4).opacity(0.15), radius: 3, y: 1.5) + + VStack(alignment: .leading, spacing: 2) { + if let release = update.release { + Text("Hotline \(release.displayVersion)") + .font(.title2) + .fontWeight(.semibold) + } + Text("A new version of Hotline is available. 🎉") + .foregroundStyle(.secondary) + } + } + } + + private var releaseNotesSection: some View { + ScrollView(.vertical) { + Markdown(releaseNotesMarkdown()) + .textSelection(.enabled) + .markdownTheme(.gitHub.text(text: { + FontSize(.em(0.85)) + })) + .font(.system(size: 14)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + .padding(.horizontal, 12) + } + .frame(minHeight: 220, maxHeight: 260) + .background( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + + private var actionRow: some View { + HStack { + Button("Not Now") { + update.remindLater() + } + .buttonBorderShape(.capsule) + .keyboardShortcut(.escape, modifiers: []) + .controlSize(.large) + .disabled(update.isDownloading) + + Spacer() + + if update.isDownloading { + ProgressView() + .controlSize(.small) + .padding(.trailing, 12) + } + + Button("Download") { + update.startDownload() + } + .buttonBorderShape(.capsule) + .keyboardShortcut(.defaultAction) + .controlSize(.large) + .disabled(update.isDownloading) + } + } + + @ViewBuilder + private func messageOnlyView(_ message: AppUpdateMessage) -> some View { + let iconName = { + switch message.kind { + case .info: + return "info.circle" + case .success: + return "checkmark.circle.fill" + case .error: + return "exclamationmark.triangle.fill" + } + }() + + HStack(alignment: .center, spacing: 12) { + + if message.kind == .success { + Text("👍") + .font(.system(size: 42)) + .shadow(color: .yellow.mix(with: .black, by: 0.3).opacity(0.2), radius: 4, y: 1.5) + } + else { + Image(systemName: iconName) + .resizable() + .scaledToFit() + .symbolRenderingMode(.multicolor) + .frame(width: 48, height: 48) + } + + VStack(alignment: .leading, spacing: 2) { + Text(message.title) + .font(.title2) + .fontWeight(.semibold) + Text(message.detail) + .foregroundStyle(.secondary) + } + + Spacer() + } + } + + private var defaultPlaceholder: some View { + VStack(alignment: .center, spacing: 12) { + Text("No update information available.") + .font(.headline) + Button("Close") { + update.acknowledgeMessage() + } + .keyboardShortcut(.defaultAction) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func releaseNotesMarkdown() -> String { + if let combined = update.releaseNotesCombined? + .trimmingCharacters(in: .whitespacesAndNewlines), + combined.isEmpty == false { + return combined + } + + let fallback = update.release?.notes.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return fallback.isEmpty ? "_No release notes provided._" : fallback + } +} + +#Preview { + AppUpdate.shared.release = UpdateReleaseInfo( + tagName: "1.0beta1", + displayVersion: "1.0b1", + versionNumber: 1.0, + buildNumber: 1, + notes: """ + - Added support for release notes in Markdown. + - Improved the update workflow for macOS users. + """, + downloadURL: URL(string: "https://example.com")!, + assetName: "Hotline.zip" + ) + AppUpdate.shared.releases = [AppUpdate.shared.release!] + AppUpdate.shared.releaseNotesCombined = nil + AppUpdate.shared.showWindow = true + return AppUpdateView() +} |