batch upload quick fix

Companion (main.py):
	•	NAVIDROME_TAGS whitelist — the single source of truth for what tags
survive
	•	enforce_tag_whitelist() — whitelist enforcer, replaces blacklist
approach
	•	All 5 write points updated: apply_tags, apply_tags_dict,
upload-track, upload-tracks, restructure_all
	•	preserve_composer and preserve_lyrics flags on both upload
endpoints (default False)
	•	/library/clean-tags now uses whitelist enforcer
iOS:
	•	UploadMetadata — preserveComposer and preserveLyrics fields
	•	buildMultipartBody — sends both flags as form fields
	•	BatchUploadView — two toggles, both off by default, wired end-to-end
	•	MultiAlbumEditorSheet — full rewrite matching
BatchAlbumEditorSheet: MusicBrainz search, swipe to exclude/include
tracks, Reset button, cover art widget with red glow
This commit is contained in:
Dallas Groot 2026-04-11 09:37:22 -07:00
parent 92a5a54b8d
commit 7a9c837650
3 changed files with 313 additions and 167 deletions

View file

@ -12,6 +12,8 @@ struct BatchUploadView: View {
@State private var genre = "" @State private var genre = ""
@State private var year = "" @State private var year = ""
@State private var keepOffline = false @State private var keepOffline = false
@State private var preserveComposer = false
@State private var preserveLyrics = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
@ -135,15 +137,17 @@ struct BatchUploadView: View {
.textInputAutocapitalization(.words) .textInputAutocapitalization(.words)
TextField("Year", text: $year) TextField("Year", text: $year)
.keyboardType(.numberPad) .keyboardType(.numberPad)
Toggle("Keep Offline After Upload", isOn: $keepOffline) Toggle("Keep Offline After Upload", isOn: $keepOffline)
.tint(accentPink) .tint(accentPink)
Toggle("Preserve Composer Tags", isOn: $preserveComposer)
.tint(accentPink)
Toggle("Preserve Lyrics Tags", isOn: $preserveLyrics)
.tint(accentPink)
} header: { } header: {
Text("Batch Tags") Text("Batch Tags")
} footer: { } footer: {
Text(keepOffline Text("Composer and Lyrics tags are stripped by default — enable only if your files have data you want to keep.")
? "Uploaded files will be kept in local storage for offline playback."
: "Uploaded files will be deleted locally. Re-download from server for offline use.")
} }
} }
@ -204,13 +208,15 @@ struct BatchUploadView: View {
} else { } else {
Button(action: { Button(action: {
let meta = UploadMetadata( let meta = UploadMetadata(
title: "", // Per-file from filename title: "",
artist: artist, artist: artist,
album: album, album: album,
albumArtist: albumArtist.isEmpty ? artist : albumArtist, albumArtist: albumArtist.isEmpty ? artist : albumArtist,
genre: genre, genre: genre,
year: year, year: year,
trackNumber: "" // Auto-assigned per file trackNumber: "",
preserveComposer: preserveComposer,
preserveLyrics: preserveLyrics
) )
manager.startBatchUpload(metadata: meta, keepOffline: keepOffline) manager.startBatchUpload(metadata: meta, keepOffline: keepOffline)
}) { }) {

View file

@ -156,6 +156,8 @@ struct UploadMetadata {
var genre: String var genre: String
var year: String var year: String
var trackNumber: String var trackNumber: String
var preserveComposer: Bool = false
var preserveLyrics: Bool = false
} }
// MARK: - Companion API Service // MARK: - Companion API Service
@ -401,6 +403,8 @@ actor CompanionAPIService {
if !metadata.genre.isEmpty { field("genre", metadata.genre) } if !metadata.genre.isEmpty { field("genre", metadata.genre) }
if !metadata.year.isEmpty { field("year", metadata.year) } if !metadata.year.isEmpty { field("year", metadata.year) }
if !metadata.trackNumber.isEmpty { field("track_number", metadata.trackNumber) } if !metadata.trackNumber.isEmpty { field("track_number", metadata.trackNumber) }
field("preserve_composer", metadata.preserveComposer ? "true" : "false")
field("preserve_lyrics", metadata.preserveLyrics ? "true" : "false")
let fileData = try Data(contentsOf: fileURL) let fileData = try Data(contentsOf: fileURL)
let filename = fileURL.lastPathComponent let filename = fileURL.lastPathComponent

View file

@ -1,38 +1,60 @@
import SwiftUI import SwiftUI
/// Batch-edit tags across one or more albums. /// Batch-edit tags across one or more albums selected from the library.
/// Fetches full album data for each ID, then applies changes to all tracks. /// Mirrors BatchAlbumEditorSheet but handles multiple albums.
struct MultiAlbumEditorSheet: View { struct MultiAlbumEditorSheet: View {
let albumIds: [String] let albumIds: [String]
var onDismiss: (() -> Void)? = nil var onDismiss: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject var serverManager: ServerManager @EnvironmentObject var serverManager: ServerManager
@State private var albums: [AlbumWithSongs] = [] @State private var albums: [AlbumWithSongs] = []
@State private var isLoadingAlbums = true @State private var isLoadingAlbums = true
@State private var albumName: String = "" // Editable fields
@State private var albumName: String = ""
@State private var albumArtist: String = "" @State private var albumArtist: String = ""
@State private var artist: String = "" @State private var artist: String = ""
@State private var genre: String = "" @State private var genre: String = ""
@State private var year: String = "" @State private var year: String = ""
@State private var applyArtistToAll = false @State private var applyArtistToAll = false
// Song removal
@State private var excludedSongIds: Set<String> = []
// MusicBrainz
@State private var isSearchingMB = false
@State private var mbResults: [MBRecording] = []
@State private var selectedMB: MBRecording?
@State private var showMBPicker = false
// Cover art
@State private var pendingCoverImage: UIImage? = nil
@State private var removeCoverArt = false
@State private var showImagePicker = false
// Save state
@State private var isSaving = false @State private var isSaving = false
@State private var progress: Double = 0 @State private var progress: Double = 0
@State private var resultMessage: String? @State private var resultMessage: String?
@State private var failedTracks: [String] = [] @State private var failedTracks: [String] = []
@ObservedObject private var settings = CompanionSettings.shared @ObservedObject private var settings = CompanionSettings.shared
private let api = CompanionAPIService() private let api = CompanionAPIService()
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private var allSongs: [Song] { private var allSongs: [Song] { albums.flatMap { $0.song ?? [] } }
albums.flatMap { $0.song ?? [] } private var includedSongs: [Song] { allSongs.filter { !excludedSongIds.contains($0.id) } }
private var coverArtSongId: String? {
includedSongs.first(where: { $0.coverArt?.hasPrefix("companion:") == true })?.coverArt
} }
private var coverArtCompanionId: String? {
guard let id = coverArtSongId, id.hasPrefix("companion:") else { return nil }
return String(id.dropFirst("companion:".count))
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { Group {
@ -48,38 +70,58 @@ struct MultiAlbumEditorSheet: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { Button("Cancel") { onDismiss?(); dismiss() }
onDismiss?() .foregroundColor(.gray)
dismiss()
}
.foregroundColor(.gray)
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
if settings.isEnabled && !isLoadingAlbums { if settings.isEnabled && !isLoadingAlbums {
Button("Apply", action: applyBatch) HStack(spacing: 14) {
.fontWeight(.semibold) Button(action: searchMusicBrainz) {
.foregroundColor(accentPink) if isSearchingMB {
.disabled(isSaving || albumName.isEmpty) ProgressView().tint(.white).scaleEffect(0.8)
} else {
Image(systemName: "magnifyingglass")
.foregroundColor(selectedMB != nil ? .green : .white)
}
}
.disabled(isSearchingMB)
Button("Apply", action: applyBatch)
.fontWeight(.semibold)
.foregroundColor(accentPink)
.disabled(isSaving || (albumName.isEmpty && pendingCoverImage == nil && !removeCoverArt))
}
} }
} }
} }
.sheet(isPresented: $showMBPicker) {
MusicBrainzPickerSheet(results: mbResults) { rec in
selectedMB = rec
showMBPicker = false
}
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView { image in
pendingCoverImage = image
removeCoverArt = false
}
}
.task { await loadAlbums() } .task { await loadAlbums() }
} }
} }
// MARK: - Loading // MARK: - Loading / No API views
private var loadingView: some View { private var loadingView: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
ProgressView() ProgressView().tint(accentPink)
.tint(accentPink)
Text("Loading \(albumIds.count) album\(albumIds.count == 1 ? "" : "s")...") Text("Loading \(albumIds.count) album\(albumIds.count == 1 ? "" : "s")...")
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundColor(.gray) .foregroundColor(.gray)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
private var noApiView: some View { private var noApiView: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
@ -94,122 +136,171 @@ struct MultiAlbumEditorSheet: View {
} }
.padding(40) .padding(40)
} }
// MARK: - Editor Form // MARK: - Editor Form
private var editorForm: some View { private var editorForm: some View {
List { List {
// Scope summary // Cover art + scope header
Section { Section {
HStack { HStack(alignment: .top, spacing: 14) {
Text("Albums") CoverArtEditorWidget(
.foregroundColor(.gray) existingCoverArtId: coverArtSongId,
pendingImage: $pendingCoverImage,
removeArt: $removeCoverArt,
showPicker: $showImagePicker,
size: 72
)
VStack(alignment: .leading, spacing: 4) {
Text(albumIds.count == 1 ? albumName : "\(albumIds.count) Albums")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
.lineLimit(2)
Text("\(includedSongs.count) of \(allSongs.count) tracks selected")
.font(.system(size: 12))
.foregroundColor(.gray)
if pendingCoverImage != nil {
Text("Cover art will be replaced")
.font(.system(size: 11))
.foregroundColor(.red.opacity(0.8))
} else if removeCoverArt {
Text("Cover art will be removed")
.font(.system(size: 11))
.foregroundColor(.red.opacity(0.8))
}
}
Spacer() Spacer()
Text("\(albums.count)")
.foregroundColor(.white)
} }
HStack { .padding(.vertical, 4)
Text("Total Tracks") } header: { Text("Selected Albums") } footer: {
.foregroundColor(.gray) Text("Changes apply to selected tracks across all albums. Navidrome rescans automatically.")
Spacer() }
Text("\(allSongs.count)")
.foregroundColor(.white) // MusicBrainz suggestion banner
} if let mb = selectedMB {
Section {
// Show album names VStack(alignment: .leading, spacing: 4) {
ForEach(albums) { album in HStack {
HStack(spacing: 8) { Image(systemName: "checkmark.seal.fill")
AsyncCoverArt(coverArtId: album.coverArt, size: 40) .foregroundColor(.green)
.frame(width: 32, height: 32)
.cornerRadius(3)
VStack(alignment: .leading, spacing: 1) {
Text(album.name)
.font(.system(size: 13)) .font(.system(size: 13))
Text("MusicBrainz: \(mb.album)")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white) .foregroundColor(.white)
.lineLimit(1) Spacer()
Text("\(album.artist ?? "Unknown") · \(album.song?.count ?? 0) tracks") Button("Change") { showMBPicker = true }
.font(.system(size: 12))
.foregroundColor(accentPink)
}
if !mb.label.isEmpty {
Text("\(mb.country) · \(mb.label)")
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.gray) .foregroundColor(.gray)
} }
} }
.padding(.vertical, 2)
} header: { Text("MusicBrainz Match") } footer: {
Text("Tap ↓ next to any field to apply the suggestion.")
} }
} header: {
Text("Selected Albums")
} footer: {
Text("Changes apply to ALL tracks across all selected albums.")
} }
// Tag fields // Album tag fields
Section { Section {
editField("Album", text: $albumName) mbEditField("Album", text: $albumName, mbValue: selectedMB?.album)
editField("Album Artist", text: $albumArtist) mbEditField("Album Artist", text: $albumArtist, mbValue: selectedMB?.albumArtist)
editField("Genre", text: $genre) mbEditField("Genre", text: $genre, mbValue: selectedMB?.genre.isEmpty == false ? selectedMB?.genre : nil)
editField("Year", text: $year, keyboard: .numberPad) mbEditField("Year", text: $year, mbValue: selectedMB?.year, keyboard: .numberPad)
} header: { } header: { Text("Album Tags") }
Text("Album Tags")
}
// Artist override // Artist override
Section { Section {
Toggle("Set same artist on all tracks", isOn: $applyArtistToAll) Toggle("Set same artist on all tracks", isOn: $applyArtistToAll)
.tint(accentPink) .tint(accentPink)
if applyArtistToAll { if applyArtistToAll {
editField("Artist", text: $artist) mbEditField("Artist", text: $artist, mbValue: selectedMB?.artist)
} }
} header: { } header: { Text("Artist Override") } footer: {
Text("Artist Override")
} footer: {
if applyArtistToAll { if applyArtistToAll {
Text("Every track gets the same artist — useful for fixing compilations that split into per-artist albums.") Text("Every selected track gets the same artist.")
} else { } else {
Text("Each track keeps its current artist.") Text("Each track keeps its current artist.")
} }
} }
// Track preview // Track list with swipe-to-remove
Section { Section {
ForEach(allSongs, id: \.id) { song in ForEach(allSongs, id: \.id) { song in
HStack(spacing: 8) { HStack(spacing: 8) {
Text("\(song.track ?? 0)") Text("\(song.track ?? 0)")
.font(.system(size: 11, design: .monospaced)) .font(.system(size: 11, design: .monospaced))
.foregroundColor(.gray) .foregroundColor(excludedSongIds.contains(song.id) ? .gray.opacity(0.4) : .gray)
.frame(width: 20) .frame(width: 20)
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text(song.title) Text(song.title)
.font(.system(size: 13)) .font(.system(size: 13))
.foregroundColor(.white) .foregroundColor(excludedSongIds.contains(song.id) ? .gray.opacity(0.4) : .white)
.lineLimit(1) .lineLimit(1)
Text(applyArtistToAll ? artist : (song.artist ?? "")) Text(applyArtistToAll ? artist : (song.artist ?? ""))
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(applyArtistToAll ? accentPink : .gray) .foregroundColor(applyArtistToAll ? accentPink.opacity(excludedSongIds.contains(song.id) ? 0.3 : 1) : .gray.opacity(excludedSongIds.contains(song.id) ? 0.4 : 1))
.lineLimit(1) .lineLimit(1)
} }
Spacer() Spacer()
if song.path != nil { if excludedSongIds.contains(song.id) {
Text("Excluded")
.font(.system(size: 10))
.foregroundColor(.gray.opacity(0.5))
} else if song.path != nil {
Image(systemName: "checkmark.circle") Image(systemName: "checkmark.circle")
.font(.system(size: 10)) .font(.system(size: 10))
.foregroundColor(.green.opacity(0.4)) .foregroundColor(.green.opacity(0.4))
} }
} }
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if excludedSongIds.contains(song.id) {
Button {
withAnimation { excludedSongIds.remove(song.id) }
} label: {
Label("Include", systemImage: "plus.circle")
}
.tint(accentPink)
} else {
Button(role: .destructive) {
withAnimation { excludedSongIds.insert(song.id) }
} label: {
Label("Exclude", systemImage: "minus.circle")
}
}
}
} }
} header: { } header: {
Text("\(allSongs.count) Tracks") HStack {
Text("Tracks (\(includedSongs.count) included)")
Spacer()
if !excludedSongIds.isEmpty {
Button("Reset") {
withAnimation { excludedSongIds.removeAll() }
}
.font(.system(size: 12))
.foregroundColor(accentPink)
}
}
} footer: {
Text("Swipe left to exclude a track from this edit.")
} }
// Progress // Progress / Results
if isSaving { if isSaving {
Section { Section {
VStack(spacing: 8) { VStack(spacing: 8) {
ProgressView(value: progress) ProgressView(value: progress).tint(accentPink)
.tint(accentPink) Text("Updating \(Int(progress * Double(includedSongs.count)))/\(includedSongs.count)...")
Text("Updating \(Int(progress * Double(allSongs.count)))/\(allSongs.count)...")
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundColor(.gray) .foregroundColor(.gray)
} }
} }
} }
if let msg = resultMessage { if let msg = resultMessage {
Section { Section {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@ -226,13 +317,12 @@ struct MultiAlbumEditorSheet: View {
} }
} }
} }
// MARK: - Load Albums // MARK: - Load Albums
private func loadAlbums() async { private func loadAlbums() async {
isLoadingAlbums = true isLoadingAlbums = true
var loaded: [AlbumWithSongs] = [] var loaded: [AlbumWithSongs] = []
for id in albumIds { for id in albumIds {
do { do {
if let album = try await serverManager.client.getAlbum(id: id) { if let album = try await serverManager.client.getAlbum(id: id) {
@ -242,64 +332,80 @@ struct MultiAlbumEditorSheet: View {
DebugLogger.shared.log("Failed to load album \(id): \(error.localizedDescription)", category: "Companion") DebugLogger.shared.log("Failed to load album \(id): \(error.localizedDescription)", category: "Companion")
} }
} }
await MainActor.run { await MainActor.run {
albums = loaded albums = loaded
isLoadingAlbums = false isLoadingAlbums = false
// Pre-fill from first album
if let first = loaded.first { if let first = loaded.first {
if albumName.isEmpty { albumName = first.name } if albumName.isEmpty { albumName = first.name }
if albumArtist.isEmpty { albumArtist = first.albumArtist ?? first.artist ?? "" } if albumArtist.isEmpty { albumArtist = first.albumArtist ?? first.artist ?? "" }
if artist.isEmpty { artist = first.albumArtist ?? first.artist ?? "" } if artist.isEmpty { artist = first.albumArtist ?? first.artist ?? "" }
if genre.isEmpty { genre = first.genre ?? "" } if genre.isEmpty { genre = first.genre ?? "" }
if year.isEmpty, let y = first.year { year = "\(y)" } if year.isEmpty, let y = first.year { year = "\(y)" }
} }
} }
} }
// MARK: - Apply Batch // MARK: - MusicBrainz
private func applyBatch() { private func searchMusicBrainz() {
let songs = allSongs isSearchingMB = true
guard !songs.isEmpty else { return } Task {
let results = await MusicBrainzService.searchAlbum(
let paths = songs.compactMap { $0.path } album: albumName, artist: albumArtist
guard !paths.isEmpty else { )
resultMessage = "No tracks have file paths" await MainActor.run {
return isSearchingMB = false
if !results.isEmpty {
mbResults = results
showMBPicker = true
}
}
} }
}
// MARK: - Apply Batch
private func applyBatch() {
guard !includedSongs.isEmpty || pendingCoverImage != nil || removeCoverArt else { return }
isSaving = true isSaving = true
progress = 0 progress = 0
failedTracks = [] failedTracks = []
resultMessage = nil resultMessage = nil
Task { Task {
do { do {
let request = BatchMetadataEditRequest( // Cover art use first included song's companion ID
relativePaths: paths, if let image = pendingCoverImage, let cid = coverArtCompanionId {
title: nil, try await api.uploadCoverArt(songId: cid, image: image)
artist: applyArtistToAll ? artist : nil, } else if removeCoverArt, let cid = coverArtCompanionId {
album: albumName.isEmpty ? nil : albumName, try await api.deleteCoverArt(songId: cid)
albumArtist: albumArtist.isEmpty ? nil : albumArtist, }
genre: genre.isEmpty ? nil : genre,
year: Int(year)
)
DebugLogger.shared.log(
"Multi-album edit: \(paths.count) tracks across \(albums.count) albums, " +
"album='\(albumName)', albumArtist='\(albumArtist)', artist=\(applyArtistToAll ? "'\(artist)'" : "nil")",
category: "Companion"
)
await MainActor.run { progress = 0.3 }
let result = try await api.batchEditMetadata(request)
// Notify the watch for every song across all albums await MainActor.run { progress = 0.2 }
for album in albums {
for song in album.song ?? [] { let paths = includedSongs.compactMap { $0.path }
if !paths.isEmpty {
let request = BatchMetadataEditRequest(
relativePaths: paths,
title: nil,
artist: applyArtistToAll ? artist : nil,
album: albumName.isEmpty ? nil : albumName,
albumArtist: albumArtist.isEmpty ? nil : albumArtist,
genre: genre.isEmpty ? nil : genre,
year: Int(year)
)
DebugLogger.shared.log(
"Multi-album edit: \(paths.count) tracks across \(albums.count) albums",
category: "Companion"
)
await MainActor.run { progress = 0.4 }
let result = try await api.batchEditMetadata(request)
for song in includedSongs {
WatchConnectivityManager.shared.notifyTagsUpdated( WatchConnectivityManager.shared.notifyTagsUpdated(
songId: song.id, songId: song.id,
title: nil, title: nil,
@ -308,20 +414,24 @@ struct MultiAlbumEditorSheet: View {
albumArtist: albumArtist.isEmpty ? nil : albumArtist albumArtist: albumArtist.isEmpty ? nil : albumArtist
) )
} }
}
await MainActor.run { await MainActor.run {
progress = 1.0 progress = 1.0
isSaving = false isSaving = false
let succeeded = result.succeeded?.count ?? 0
let succeeded = result.succeeded?.count ?? 0 let failed = result.failed ?? []
let failed = result.failed ?? [] if failed.isEmpty {
resultMessage = "✓ All \(succeeded) tracks updated"
if failed.isEmpty { } else {
resultMessage = "✓ All \(succeeded) tracks updated" resultMessage = "\(succeeded)/\(paths.count) succeeded, \(failed.count) failed"
} else { failedTracks = failed.map { "\($0.path): \($0.error)" }
resultMessage = "\(succeeded)/\(paths.count) succeeded, \(failed.count) failed" }
failedTracks = failed.map { "\($0.path): \($0.error)" } }
} else {
await MainActor.run {
progress = 1.0
isSaving = false
resultMessage = "✓ Cover art updated"
} }
} }
} catch { } catch {
@ -332,19 +442,45 @@ struct MultiAlbumEditorSheet: View {
} }
} }
} }
// MARK: - Helpers // MARK: - Field with optional MB suggestion pill
private func editField(_ label: String, text: Binding<String>, keyboard: UIKeyboardType = .default) -> some View { private func mbEditField(
HStack { _ label: String,
Text(label) text: Binding<String>,
.foregroundColor(.gray) mbValue: String?,
.frame(width: 100, alignment: .leading) keyboard: UIKeyboardType = .default
TextField(label, text: text) ) -> some View {
.keyboardType(keyboard) VStack(alignment: .leading, spacing: 4) {
.multilineTextAlignment(.trailing) HStack {
.keyboardDoneButton() Text(label)
.foregroundColor(.gray)
.frame(width: 100, alignment: .leading)
TextField(label, text: text)
.keyboardType(keyboard)
.multilineTextAlignment(.trailing)
.keyboardDoneButton()
}
.font(.system(size: 14))
if let mb = mbValue, !mb.isEmpty, mb != text.wrappedValue {
Button {
text.wrappedValue = mb
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.down.circle.fill").font(.system(size: 10))
Text(mb).font(.system(size: 11)).lineLimit(1)
}
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.blue.opacity(0.35))
.cornerRadius(8)
}
.buttonStyle(.plain)
.padding(.leading, 4)
}
} }
.font(.system(size: 14)) .padding(.vertical, mbValue != nil && mbValue != text.wrappedValue ? 2 : 0)
} }
} }