From d392e532e03a7c9bd37c356b256e97059e8b5c83 Mon Sep 17 00:00:00 2001 From: diyaa Date: Thu, 4 Jun 2026 07:54:00 +0200 Subject: [PATCH] Polish iPhone library UI --- .../Sources/iPhoneLibraryView.swift | 314 ++++++++++++------ .../iPhoneLibraryViewModelPlaybackTests.swift | 13 + 2 files changed, 220 insertions(+), 107 deletions(-) diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift index 19be807..a7f6fbb 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift @@ -4,6 +4,12 @@ import VelodyDomain import UIKit #endif +enum NowPlayingCardLayoutMetrics { + static func artworkHeight(for contentWidth: CGFloat) -> CGFloat { + min(max(contentWidth * 0.42, 148), 176) + } +} + struct iPhoneLibraryView: View { private enum LayoutMetrics { static let horizontalPadding: CGFloat = 16 @@ -12,11 +18,28 @@ struct iPhoneLibraryView: View { static let bottomPadding: CGFloat = 24 static let compactContentThreshold: CGFloat = 350 static let rowArtworkSize: CGFloat = 60 - static let rowFavoriteButtonSize: CGFloat = 36 - static let nowPlayingFavoriteButtonSize: CGFloat = 48 - static let rowActionMinHeight: CGFloat = 38 - static let nowPlayingActionMinHeight: CGFloat = 48 + static let controlButtonHeight: CGFloat = 36 + static let controlButtonMinWidth: CGFloat = 72 + static let controlButtonHorizontalPadding: CGFloat = 14 + static let controlButtonSymbolSize: CGFloat = 13 + static let controlButtonSpacing: CGFloat = 6 + static let favoriteButtonSize: CGFloat = 36 + static let favoriteButtonSymbolSize: CGFloat = 14 static let badgeMinHeight: CGFloat = 28 + static let badgeHorizontalPadding: CGFloat = 12 + static let badgeVerticalPadding: CGFloat = 6 + static let controlGroupSpacing: CGFloat = 8 + } + + private enum ActionButtonRole { + case primary + case secondary + } + + private struct ActionButtonColors { + let foreground: Color + let background: Color + let border: Color } private struct RootLayout { @@ -49,6 +72,11 @@ struct iPhoneLibraryView: View { var trackRowPadding: CGFloat { isCompact ? 12 : 14 } + + var nowPlayingArtworkHeight: CGFloat { + NowPlayingCardLayoutMetrics.artworkHeight(for: contentWidth) + } + } @State private var viewModel = iPhoneLibraryViewModel() @@ -168,7 +196,7 @@ struct iPhoneLibraryView: View { VStack(alignment: .leading, spacing: 16) { ViewThatFits(in: .vertical) { HStack(alignment: .top, spacing: 12) { - nowPlayingHeaderText(card) + nowPlayingHeaderText Spacer(minLength: 12) @@ -176,25 +204,31 @@ struct iPhoneLibraryView: View { } VStack(alignment: .leading, spacing: 10) { - nowPlayingHeaderText(card) + nowPlayingHeaderText statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge)) } } - ArtworkCoverView(localFilePath: card.artworkLocalFilePath) - .frame(maxWidth: .infinity) - .frame(height: 220) - - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { Text(card.title) - .font(.title3.weight(.semibold)) + .font(.title2.weight(.bold)) .foregroundStyle(.primary) - .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + Text(card.artist) .font(.subheadline) .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + + Text(card.playbackStateText) + .font(.footnote.weight(.medium)) + .foregroundStyle(playbackStateColor(for: viewModel.nowPlaying.playbackState)) .lineLimit(1) } + .frame(maxWidth: .infinity, alignment: .leading) + + nowPlayingArtworkView(localFilePath: card.artworkLocalFilePath, layout: layout) VStack(spacing: 8) { Slider( @@ -319,7 +353,8 @@ struct iPhoneLibraryView: View { badgeColor: statusColor(for: track.status), detailText: track.statusDetailText, actionTitle: track.canPlay ? track.playButtonTitle : track.downloadButtonTitle, - actionProminence: track.canPlay, + actionSystemImage: remoteTrackActionSystemImage(for: track), + actionRole: remoteTrackActionRole(for: track), actionEnabled: track.canPlay || track.canDownload, favoriteAction: { Task { @@ -350,7 +385,8 @@ struct iPhoneLibraryView: View { badgeColor: .green, detailText: nil, actionTitle: track.playButtonTitle, - actionProminence: true, + actionSystemImage: playbackActionSystemImage(isPlaying: track.playButtonTitle == "Pause"), + actionRole: .primary, actionEnabled: true, favoriteAction: { Task { @@ -374,7 +410,8 @@ struct iPhoneLibraryView: View { badgeColor: Color, detailText: String?, actionTitle: String, - actionProminence: Bool, + actionSystemImage: String, + actionRole: ActionButtonRole, actionEnabled: Bool, favoriteAction: @escaping () -> Void, action: @escaping () -> Void, @@ -402,7 +439,8 @@ struct iPhoneLibraryView: View { rowActionButton( title: actionTitle, - isProminent: actionProminence, + systemImage: actionSystemImage, + role: actionRole, isEnabled: actionEnabled, action: action ) @@ -425,7 +463,8 @@ struct iPhoneLibraryView: View { rowActionButton( title: actionTitle, - isProminent: actionProminence, + systemImage: actionSystemImage, + role: actionRole, isEnabled: actionEnabled, action: action ) @@ -454,48 +493,53 @@ struct iPhoneLibraryView: View { @ViewBuilder private func rowActionButton( title: String, - isProminent: Bool, + systemImage: String, + role: ActionButtonRole, isEnabled: Bool, action: @escaping () -> Void ) -> some View { - if isProminent { - Button(action: action) { - rowActionButtonLabel(title: title) - } - .buttonStyle(.borderedProminent) - .disabled(!isEnabled) - } else { - Button(action: action) { - rowActionButtonLabel(title: title) - } - .buttonStyle(.bordered) - .disabled(!isEnabled) - } - } - - private func rowActionButtonLabel(title: String) -> some View { - Text(title) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.75) - .allowsTightening(true) - .frame( - minHeight: LayoutMetrics.rowActionMinHeight + Button(action: action) { + actionButtonLabel( + title: title, + systemImage: systemImage, + role: role, + isEnabled: isEnabled ) + } + .buttonStyle(.plain) + .disabled(!isEnabled) } - private func nowPlayingActionButtonLabel(title: String, systemImage: String) -> some View { - HStack(spacing: 8) { + private func actionButtonLabel( + title: String, + systemImage: String, + role: ActionButtonRole, + isEnabled: Bool + ) -> some View { + let colors = actionButtonColors(for: role, isEnabled: isEnabled) + + return HStack(spacing: LayoutMetrics.controlButtonSpacing) { Image(systemName: systemImage) + .font(.system(size: LayoutMetrics.controlButtonSymbolSize, weight: .semibold)) Text(title) + .font(.subheadline.weight(.semibold)) .lineLimit(1) - .minimumScaleFactor(0.9) + .minimumScaleFactor(0.82) .allowsTightening(true) } - .font(.headline) + .foregroundStyle(colors.foreground) + .padding(.horizontal, LayoutMetrics.controlButtonHorizontalPadding) .frame( - maxWidth: .infinity, - minHeight: LayoutMetrics.nowPlayingActionMinHeight + minWidth: LayoutMetrics.controlButtonMinWidth, + minHeight: LayoutMetrics.controlButtonHeight + ) + .background( + Capsule() + .fill(colors.background) + ) + .overlay( + Capsule() + .strokeBorder(colors.border, lineWidth: 1) ) } @@ -554,19 +598,6 @@ struct iPhoneLibraryView: View { } } - private func favoriteButton( - isFavorite: Bool, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Image(systemName: isFavorite ? "heart.fill" : "heart") - .font(.title3.weight(.semibold)) - .foregroundStyle(isFavorite ? .red : .secondary) - } - .buttonStyle(.borderless) - .accessibilityLabel(isFavorite ? "Remove Favorite" : "Add Favorite") - } - private func badgeColor(for badge: NowPlayingDownloadBadge) -> Color { switch badge { case .downloaded: @@ -598,25 +629,25 @@ struct iPhoneLibraryView: View { .lineLimit(1) .minimumScaleFactor(0.75) .allowsTightening(true) - .padding(.horizontal, 10) + .padding(.horizontal, LayoutMetrics.badgeHorizontalPadding) + .padding(.vertical, LayoutMetrics.badgeVerticalPadding) .frame(minHeight: LayoutMetrics.badgeMinHeight) - .multilineTextAlignment(.center) + .fixedSize(horizontal: true, vertical: false) .background(color.opacity(0.14), in: Capsule()) + .overlay( + Capsule() + .strokeBorder(color.opacity(0.18), lineWidth: 1) + ) } - private func nowPlayingHeaderText(_ card: NowPlayingCardViewData) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text("Now Playing") - .font(.headline) - Text(card.playbackStateText) - .font(.subheadline) - .foregroundStyle(playbackStateColor(for: viewModel.nowPlaying.playbackState)) - } + private var nowPlayingHeaderText: some View { + Text("Now Playing") + .font(.headline) } private func nowPlayingControls(_ card: NowPlayingCardViewData) -> some View { ViewThatFits(in: .vertical) { - HStack(spacing: 12) { + HStack(spacing: LayoutMetrics.controlGroupSpacing) { if viewModel.nowPlayingFavoriteTrackID == card.trackID { nowPlayingFavoriteButton(trackID: card.trackID) } @@ -630,7 +661,7 @@ struct iPhoneLibraryView: View { nowPlayingFavoriteButton(trackID: card.trackID) } - HStack(spacing: 12) { + HStack(spacing: LayoutMetrics.controlGroupSpacing) { nowPlayingToggleButton(card) nowPlayingStopButton } @@ -640,43 +671,33 @@ struct iPhoneLibraryView: View { } private func nowPlayingFavoriteButton(trackID: String) -> some View { - favoriteButton(isFavorite: viewModel.isNowPlayingTrackFavorite) { + favoriteControlButton(isFavorite: viewModel.isNowPlayingTrackFavorite) { Task { await viewModel.toggleFavorite(trackID: trackID) } } - .frame( - width: LayoutMetrics.nowPlayingFavoriteButtonSize, - height: LayoutMetrics.nowPlayingFavoriteButtonSize - ) - .background( - Color.secondary.opacity(0.08), - in: RoundedRectangle(cornerRadius: 14, style: .continuous) - ) } private func nowPlayingToggleButton(_ card: NowPlayingCardViewData) -> some View { - Button { + rowActionButton( + title: card.isPlaying ? "Pause" : "Play", + systemImage: playbackActionSystemImage(isPlaying: card.isPlaying), + role: .primary, + isEnabled: true + ) { viewModel.togglePlayback(trackID: card.trackID) - } label: { - nowPlayingActionButtonLabel( - title: card.isPlaying ? "Pause" : "Play", - systemImage: card.isPlaying ? "pause.fill" : "play.fill" - ) } - .buttonStyle(.borderedProminent) } private var nowPlayingStopButton: some View { - Button { + rowActionButton( + title: "Stop", + systemImage: "stop.fill", + role: .secondary, + isEnabled: true + ) { viewModel.stopPlayback() - } label: { - nowPlayingActionButtonLabel( - title: "Stop", - systemImage: "stop.fill" - ) } - .buttonStyle(.bordered) } private func libraryTrackText( @@ -710,32 +731,111 @@ struct iPhoneLibraryView: View { action: @escaping () -> Void, isFavorite: Bool ) -> some View { - favoriteButton(isFavorite: isFavorite, action: action) - .frame( - width: LayoutMetrics.rowFavoriteButtonSize, - height: LayoutMetrics.rowFavoriteButtonSize + favoriteControlButton(isFavorite: isFavorite, action: action) + } + + private func nowPlayingArtworkView( + localFilePath: String?, + layout: RootLayout + ) -> some View { + NowPlayingArtworkView( + localFilePath: localFilePath, + height: layout.nowPlayingArtworkHeight + ) + } + + private func favoriteControlButton( + isFavorite: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: isFavorite ? "heart.fill" : "heart") + .font(.system(size: LayoutMetrics.favoriteButtonSymbolSize, weight: .semibold)) + .foregroundStyle(isFavorite ? .red : .gray) + .frame( + width: LayoutMetrics.favoriteButtonSize, + height: LayoutMetrics.favoriteButtonSize + ) + .background( + Capsule() + .fill(Color(uiColor: .tertiarySystemFill)) + ) + .overlay( + Capsule() + .strokeBorder(Color(uiColor: .separator).opacity(0.12), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(isFavorite ? "Remove Favorite" : "Add Favorite") + } + + private func actionButtonColors(for role: ActionButtonRole, isEnabled: Bool) -> ActionButtonColors { + switch role { + case .primary: + return ActionButtonColors( + foreground: .white.opacity(isEnabled ? 1 : 0.92), + background: Color.blue.opacity(isEnabled ? 1 : 0.42), + border: Color.blue.opacity(isEnabled ? 0.22 : 0.12) ) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.secondary.opacity(0.08)) + case .secondary: + return ActionButtonColors( + foreground: isEnabled ? .primary : .secondary, + background: Color(uiColor: .systemGray5).opacity(isEnabled ? 1 : 0.78), + border: Color(uiColor: .separator).opacity(isEnabled ? 0.14 : 0.08) ) + } + } + + private func playbackActionSystemImage(isPlaying: Bool) -> String { + isPlaying ? "pause.fill" : "play.fill" + } + + private func remoteTrackActionSystemImage(for track: RemoteTrackRowViewData) -> String { + if track.canPlay { + return playbackActionSystemImage(isPlaying: track.playButtonTitle == "Pause") + } + + switch track.status { + case .notDownloaded, .downloading: + return "arrow.down" + case .downloaded: + return "play.fill" + case .missing, .failed: + return "arrow.clockwise" + } + } + + private func remoteTrackActionRole(for track: RemoteTrackRowViewData) -> ActionButtonRole { + if track.canPlay || track.status == .missing || track.status == .failed { + return .primary + } + + return .secondary } } -private struct ArtworkCoverView: View { +private struct NowPlayingArtworkView: View { let localFilePath: String? + let height: CGFloat + + private let cornerRadius: CGFloat = 24 + private let placeholderIconSize: CGFloat = 54 var body: some View { - Group { + ZStack { if let artworkImage { Image(uiImage: artworkImage) .resizable() .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - artworkPlaceholder(cornerRadius: 24, iconSize: 54) + artworkPlaceholder(cornerRadius: cornerRadius, iconSize: placeholderIconSize) } } - .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .frame(maxWidth: .infinity) + .frame(height: height) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) } private var artworkImage: UIImage? { diff --git a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift index 970570e..244e983 100644 --- a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift +++ b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift @@ -8,6 +8,19 @@ import VelodyPersistence @MainActor final class iPhoneLibraryViewModelPlaybackTests: XCTestCase { + func testNowPlayingArtworkHeightStaysWithinRequestedRange() { + let contentWidths: [CGFloat] = [280, 343, 361, 398, 520] + + for width in contentWidths { + let artworkHeight = NowPlayingCardLayoutMetrics.artworkHeight(for: width) + XCTAssertGreaterThanOrEqual(artworkHeight, 140) + XCTAssertLessThanOrEqual(artworkHeight, 180) + } + + XCTAssertEqual(NowPlayingCardLayoutMetrics.artworkHeight(for: 280), 148) + XCTAssertEqual(NowPlayingCardLayoutMetrics.artworkHeight(for: 520), 176) + } + func testNowPlayingCardShowsTitleArtistAndArtwork() async throws { let artwork = RemoteArtwork( artworkId: "artwork-midnight",