Polish iPhone library UI
This commit is contained in:
parent
a4f83ef151
commit
d392e532e0
@ -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? {
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user