Polish iPhone library UI

This commit is contained in:
diyaa 2026-06-04 07:54:00 +02:00
parent a4f83ef151
commit d392e532e0
2 changed files with 220 additions and 107 deletions

View File

@ -4,6 +4,12 @@ import VelodyDomain
import UIKit import UIKit
#endif #endif
enum NowPlayingCardLayoutMetrics {
static func artworkHeight(for contentWidth: CGFloat) -> CGFloat {
min(max(contentWidth * 0.42, 148), 176)
}
}
struct iPhoneLibraryView: View { struct iPhoneLibraryView: View {
private enum LayoutMetrics { private enum LayoutMetrics {
static let horizontalPadding: CGFloat = 16 static let horizontalPadding: CGFloat = 16
@ -12,11 +18,28 @@ struct iPhoneLibraryView: View {
static let bottomPadding: CGFloat = 24 static let bottomPadding: CGFloat = 24
static let compactContentThreshold: CGFloat = 350 static let compactContentThreshold: CGFloat = 350
static let rowArtworkSize: CGFloat = 60 static let rowArtworkSize: CGFloat = 60
static let rowFavoriteButtonSize: CGFloat = 36 static let controlButtonHeight: CGFloat = 36
static let nowPlayingFavoriteButtonSize: CGFloat = 48 static let controlButtonMinWidth: CGFloat = 72
static let rowActionMinHeight: CGFloat = 38 static let controlButtonHorizontalPadding: CGFloat = 14
static let nowPlayingActionMinHeight: CGFloat = 48 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 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 { private struct RootLayout {
@ -49,6 +72,11 @@ struct iPhoneLibraryView: View {
var trackRowPadding: CGFloat { var trackRowPadding: CGFloat {
isCompact ? 12 : 14 isCompact ? 12 : 14
} }
var nowPlayingArtworkHeight: CGFloat {
NowPlayingCardLayoutMetrics.artworkHeight(for: contentWidth)
}
} }
@State private var viewModel = iPhoneLibraryViewModel() @State private var viewModel = iPhoneLibraryViewModel()
@ -168,7 +196,7 @@ struct iPhoneLibraryView: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
ViewThatFits(in: .vertical) { ViewThatFits(in: .vertical) {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
nowPlayingHeaderText(card) nowPlayingHeaderText
Spacer(minLength: 12) Spacer(minLength: 12)
@ -176,25 +204,31 @@ struct iPhoneLibraryView: View {
} }
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
nowPlayingHeaderText(card) nowPlayingHeaderText
statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge)) statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge))
} }
} }
ArtworkCoverView(localFilePath: card.artworkLocalFilePath) VStack(alignment: .leading, spacing: 8) {
.frame(maxWidth: .infinity)
.frame(height: 220)
VStack(alignment: .leading, spacing: 6) {
Text(card.title) Text(card.title)
.font(.title3.weight(.semibold)) .font(.title2.weight(.bold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
.lineLimit(2) .fixedSize(horizontal: false, vertical: true)
Text(card.artist) Text(card.artist)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
Text(card.playbackStateText)
.font(.footnote.weight(.medium))
.foregroundStyle(playbackStateColor(for: viewModel.nowPlaying.playbackState))
.lineLimit(1) .lineLimit(1)
} }
.frame(maxWidth: .infinity, alignment: .leading)
nowPlayingArtworkView(localFilePath: card.artworkLocalFilePath, layout: layout)
VStack(spacing: 8) { VStack(spacing: 8) {
Slider( Slider(
@ -319,7 +353,8 @@ struct iPhoneLibraryView: View {
badgeColor: statusColor(for: track.status), badgeColor: statusColor(for: track.status),
detailText: track.statusDetailText, detailText: track.statusDetailText,
actionTitle: track.canPlay ? track.playButtonTitle : track.downloadButtonTitle, actionTitle: track.canPlay ? track.playButtonTitle : track.downloadButtonTitle,
actionProminence: track.canPlay, actionSystemImage: remoteTrackActionSystemImage(for: track),
actionRole: remoteTrackActionRole(for: track),
actionEnabled: track.canPlay || track.canDownload, actionEnabled: track.canPlay || track.canDownload,
favoriteAction: { favoriteAction: {
Task { Task {
@ -350,7 +385,8 @@ struct iPhoneLibraryView: View {
badgeColor: .green, badgeColor: .green,
detailText: nil, detailText: nil,
actionTitle: track.playButtonTitle, actionTitle: track.playButtonTitle,
actionProminence: true, actionSystemImage: playbackActionSystemImage(isPlaying: track.playButtonTitle == "Pause"),
actionRole: .primary,
actionEnabled: true, actionEnabled: true,
favoriteAction: { favoriteAction: {
Task { Task {
@ -374,7 +410,8 @@ struct iPhoneLibraryView: View {
badgeColor: Color, badgeColor: Color,
detailText: String?, detailText: String?,
actionTitle: String, actionTitle: String,
actionProminence: Bool, actionSystemImage: String,
actionRole: ActionButtonRole,
actionEnabled: Bool, actionEnabled: Bool,
favoriteAction: @escaping () -> Void, favoriteAction: @escaping () -> Void,
action: @escaping () -> Void, action: @escaping () -> Void,
@ -402,7 +439,8 @@ struct iPhoneLibraryView: View {
rowActionButton( rowActionButton(
title: actionTitle, title: actionTitle,
isProminent: actionProminence, systemImage: actionSystemImage,
role: actionRole,
isEnabled: actionEnabled, isEnabled: actionEnabled,
action: action action: action
) )
@ -425,7 +463,8 @@ struct iPhoneLibraryView: View {
rowActionButton( rowActionButton(
title: actionTitle, title: actionTitle,
isProminent: actionProminence, systemImage: actionSystemImage,
role: actionRole,
isEnabled: actionEnabled, isEnabled: actionEnabled,
action: action action: action
) )
@ -454,48 +493,53 @@ struct iPhoneLibraryView: View {
@ViewBuilder @ViewBuilder
private func rowActionButton( private func rowActionButton(
title: String, title: String,
isProminent: Bool, systemImage: String,
role: ActionButtonRole,
isEnabled: Bool, isEnabled: Bool,
action: @escaping () -> Void action: @escaping () -> Void
) -> some View { ) -> some View {
if isProminent { Button(action: action) {
Button(action: action) { actionButtonLabel(
rowActionButtonLabel(title: title) title: title,
} systemImage: systemImage,
.buttonStyle(.borderedProminent) role: role,
.disabled(!isEnabled) isEnabled: 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
) )
}
.buttonStyle(.plain)
.disabled(!isEnabled)
} }
private func nowPlayingActionButtonLabel(title: String, systemImage: String) -> some View { private func actionButtonLabel(
HStack(spacing: 8) { 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) Image(systemName: systemImage)
.font(.system(size: LayoutMetrics.controlButtonSymbolSize, weight: .semibold))
Text(title) Text(title)
.font(.subheadline.weight(.semibold))
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.9) .minimumScaleFactor(0.82)
.allowsTightening(true) .allowsTightening(true)
} }
.font(.headline) .foregroundStyle(colors.foreground)
.padding(.horizontal, LayoutMetrics.controlButtonHorizontalPadding)
.frame( .frame(
maxWidth: .infinity, minWidth: LayoutMetrics.controlButtonMinWidth,
minHeight: LayoutMetrics.nowPlayingActionMinHeight 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 { private func badgeColor(for badge: NowPlayingDownloadBadge) -> Color {
switch badge { switch badge {
case .downloaded: case .downloaded:
@ -598,25 +629,25 @@ struct iPhoneLibraryView: View {
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.75) .minimumScaleFactor(0.75)
.allowsTightening(true) .allowsTightening(true)
.padding(.horizontal, 10) .padding(.horizontal, LayoutMetrics.badgeHorizontalPadding)
.padding(.vertical, LayoutMetrics.badgeVerticalPadding)
.frame(minHeight: LayoutMetrics.badgeMinHeight) .frame(minHeight: LayoutMetrics.badgeMinHeight)
.multilineTextAlignment(.center) .fixedSize(horizontal: true, vertical: false)
.background(color.opacity(0.14), in: Capsule()) .background(color.opacity(0.14), in: Capsule())
.overlay(
Capsule()
.strokeBorder(color.opacity(0.18), lineWidth: 1)
)
} }
private func nowPlayingHeaderText(_ card: NowPlayingCardViewData) -> some View { private var nowPlayingHeaderText: some View {
VStack(alignment: .leading, spacing: 6) { Text("Now Playing")
Text("Now Playing") .font(.headline)
.font(.headline)
Text(card.playbackStateText)
.font(.subheadline)
.foregroundStyle(playbackStateColor(for: viewModel.nowPlaying.playbackState))
}
} }
private func nowPlayingControls(_ card: NowPlayingCardViewData) -> some View { private func nowPlayingControls(_ card: NowPlayingCardViewData) -> some View {
ViewThatFits(in: .vertical) { ViewThatFits(in: .vertical) {
HStack(spacing: 12) { HStack(spacing: LayoutMetrics.controlGroupSpacing) {
if viewModel.nowPlayingFavoriteTrackID == card.trackID { if viewModel.nowPlayingFavoriteTrackID == card.trackID {
nowPlayingFavoriteButton(trackID: card.trackID) nowPlayingFavoriteButton(trackID: card.trackID)
} }
@ -630,7 +661,7 @@ struct iPhoneLibraryView: View {
nowPlayingFavoriteButton(trackID: card.trackID) nowPlayingFavoriteButton(trackID: card.trackID)
} }
HStack(spacing: 12) { HStack(spacing: LayoutMetrics.controlGroupSpacing) {
nowPlayingToggleButton(card) nowPlayingToggleButton(card)
nowPlayingStopButton nowPlayingStopButton
} }
@ -640,43 +671,33 @@ struct iPhoneLibraryView: View {
} }
private func nowPlayingFavoriteButton(trackID: String) -> some View { private func nowPlayingFavoriteButton(trackID: String) -> some View {
favoriteButton(isFavorite: viewModel.isNowPlayingTrackFavorite) { favoriteControlButton(isFavorite: viewModel.isNowPlayingTrackFavorite) {
Task { Task {
await viewModel.toggleFavorite(trackID: trackID) 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 { 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) 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 { private var nowPlayingStopButton: some View {
Button { rowActionButton(
title: "Stop",
systemImage: "stop.fill",
role: .secondary,
isEnabled: true
) {
viewModel.stopPlayback() viewModel.stopPlayback()
} label: {
nowPlayingActionButtonLabel(
title: "Stop",
systemImage: "stop.fill"
)
} }
.buttonStyle(.bordered)
} }
private func libraryTrackText( private func libraryTrackText(
@ -710,32 +731,111 @@ struct iPhoneLibraryView: View {
action: @escaping () -> Void, action: @escaping () -> Void,
isFavorite: Bool isFavorite: Bool
) -> some View { ) -> some View {
favoriteButton(isFavorite: isFavorite, action: action) favoriteControlButton(isFavorite: isFavorite, action: action)
.frame( }
width: LayoutMetrics.rowFavoriteButtonSize,
height: LayoutMetrics.rowFavoriteButtonSize 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( case .secondary:
RoundedRectangle(cornerRadius: 12, style: .continuous) return ActionButtonColors(
.fill(Color.secondary.opacity(0.08)) 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 localFilePath: String?
let height: CGFloat
private let cornerRadius: CGFloat = 24
private let placeholderIconSize: CGFloat = 54
var body: some View { var body: some View {
Group { ZStack {
if let artworkImage { if let artworkImage {
Image(uiImage: artworkImage) Image(uiImage: artworkImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } 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? { private var artworkImage: UIImage? {

View File

@ -8,6 +8,19 @@ import VelodyPersistence
@MainActor @MainActor
final class iPhoneLibraryViewModelPlaybackTests: XCTestCase { 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 { func testNowPlayingCardShowsTitleArtistAndArtwork() async throws {
let artwork = RemoteArtwork( let artwork = RemoteArtwork(
artworkId: "artwork-midnight", artworkId: "artwork-midnight",