[tvOS] Delete User from User Selection Screen (#1359)

* Extract handlers into function

* Color Improvements to move away from UIColor

* Bring over edit user feature from iOS

* Fix UserGridButton overlay when editing

* Move advanced menu to be near server select menu

* Re-enable context menu

* Add bottom button bar

* hook up user deletion

* improvements

* Refactor buttons for highlight hover effect

* Pass in user count

* Don't cancel editing if delete alert is cancelled

* cleanup

* Pad bottom of buttons

* Cancel editing after user deletion

* Revert ServerSelectionMenu back to button

* Remove padding that pushed the server selection menu up too far

* Make delete button red to match iOS

* Update SelectUserView.swift

* workaround Menu layout issues

* Bring select/deselect all users behavior from iOS

* Fixes after merge with main

* Fix vertical focus

---------

Co-authored-by: chickdan <=>
Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Daniel Chick 2024-12-31 15:19:23 -06:00 committed by GitHub
parent 486995b0cf
commit cd94142a8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 319 additions and 114 deletions

View File

@ -22,9 +22,9 @@ extension Color {
// TODO: Correct and add colors // TODO: Correct and add colors
#if os(tvOS) // tvOS doesn't have these #if os(tvOS) // tvOS doesn't have these
static let systemFill = Color(UIColor.white) static let systemFill = Color.white
static let secondarySystemFill = Color(UIColor.gray) static let secondarySystemFill = Color.gray
static let tertiarySystemFill = Color(UIColor.black) static let tertiarySystemFill = Color.black
static let lightGray = Color(UIColor.lightGray) static let lightGray = Color(UIColor.lightGray)
#else #else

View File

@ -109,7 +109,7 @@ extension Backport where Content: View {
extension ButtonBorderShape { extension ButtonBorderShape {
static let circleBackport: ButtonBorderShape = { static let circleBackport: ButtonBorderShape = {
if #available(iOS 17, tvOS 16.4, *) { if #available(iOS 17, *) {
return ButtonBorderShape.circle return ButtonBorderShape.circle
} else { } else {
return ButtonBorderShape.roundedRectangle return ButtonBorderShape.roundedRectangle

View File

@ -57,21 +57,22 @@ extension SelectUserView {
} }
.clipShape(.circle) .clipShape(.circle)
.aspectRatio(1, contentMode: .fill) .aspectRatio(1, contentMode: .fill)
.hoverEffect(.highlight)
Text(L10n.addUser)
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(isEnabled ? .primary : .secondary)
if serverSelection == .all {
Text(L10n.hidden)
.font(.footnote)
.hidden()
}
} }
.buttonStyle(.card) .buttonStyle(.borderless)
.buttonBorderShape(.circleBackport) .buttonBorderShape(.circle)
.disabled(!isEnabled) .disabled(!isEnabled)
Text(L10n.addUser)
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(isEnabled ? .primary : .secondary)
if serverSelection == .all {
Text(L10n.hidden)
.font(.footnote)
.hidden()
}
} }
} }
} }

View File

@ -0,0 +1,151 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import SwiftUI
extension SelectUserView {
struct SelectUserBottomBar: View {
@Binding
private var isEditing: Bool
@Binding
private var serverSelection: SelectUserServerSelection
@ObservedObject
private var viewModel: SelectUserViewModel
private let areUsersSelected: Bool
private let userCount: Int
private let onDelete: () -> Void
private let toggleAllUsersSelected: () -> Void
// MARK: - Advanced Menu
@ViewBuilder
private var advancedMenu: some View {
Menu(L10n.advanced, systemImage: "gearshape.fill") {
Button(L10n.editUsers, systemImage: "person.crop.circle") {
isEditing.toggle()
}
// TODO: Do we want to support a grid view and list view like iOS?
// if !viewModel.servers.isEmpty {
// Picker(selection: $userListDisplayType) {
// ForEach(LibraryDisplayType.allCases, id: \.hashValue) {
// Label($0.displayTitle, systemImage: $0.systemImage)
// .tag($0)
// }
// } label: {
// Text(L10n.layout)
// Text(userListDisplayType.displayTitle)
// Image(systemName: userListDisplayType.systemImage)
// }
// .pickerStyle(.menu)
// }
// TODO: Advanced settings on tvOS?
// Section {
// Button(L10n.advanced, systemImage: "gearshape.fill") {
// router.route(to: \.advancedSettings)
// }
// }
}
.labelStyle(.iconOnly)
}
private var deleteUsersButton: some View {
Button {
onDelete()
} label: {
ZStack {
Color.red
Text(L10n.delete)
.font(.body.weight(.semibold))
.foregroundStyle(areUsersSelected ? .primary : .secondary)
if !areUsersSelected {
Color.black
.opacity(0.5)
}
}
.frame(width: 400, height: 65)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.disabled(!areUsersSelected)
.buttonStyle(.card)
}
init(
isEditing: Binding<Bool>,
serverSelection: Binding<SelectUserServerSelection>,
areUsersSelected: Bool,
viewModel: SelectUserViewModel,
userCount: Int,
onDelete: @escaping () -> Void,
toggleAllUsersSelected: @escaping () -> Void
) {
self._isEditing = isEditing
self._serverSelection = serverSelection
self.viewModel = viewModel
self.areUsersSelected = areUsersSelected
self.userCount = userCount
self.onDelete = onDelete
self.toggleAllUsersSelected = toggleAllUsersSelected
}
@ViewBuilder
private var contentView: some View {
HStack(alignment: .center) {
if isEditing {
deleteUsersButton
Button {
toggleAllUsersSelected()
} label: {
Text(areUsersSelected ? L10n.removeAll : L10n.selectAll)
.font(.body.weight(.semibold))
.foregroundStyle(Color.primary)
}
Button {
isEditing = false
} label: {
L10n.cancel.text
.font(.body.weight(.semibold))
.foregroundStyle(Color.primary)
}
} else {
ServerSelectionMenu(
selection: $serverSelection,
viewModel: viewModel
)
if userCount > 1 {
advancedMenu
}
}
}
}
var body: some View {
// `Menu` with custom label has some weird additional
// frame/padding that differs from default label style
AlternateLayoutView(alignment: .top) {
Color.clear
.frame(height: 100)
} content: {
contentView
}
}
}
}

View File

@ -90,12 +90,9 @@ extension SelectUserView {
} }
.font(.body.weight(.semibold)) .font(.body.weight(.semibold))
.foregroundStyle(Color.primary) .foregroundStyle(Color.primary)
.frame(height: 50) .frame(width: 400, height: 50)
.frame(maxWidth: 400)
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
.menuOrder(.fixed) .menuOrder(.fixed)
.padding()
} }
} }
} }

View File

@ -60,62 +60,61 @@ extension SelectUserView {
.aspectRatio(1, contentMode: .fill) .aspectRatio(1, contentMode: .fill)
} }
@ViewBuilder
private var userImage: some View {
UserProfileImage(
userID: user.id,
source: user.profileImageSource(
client: server.client,
maxWidth: 120
)
)
.aspectRatio(1, contentMode: .fill)
.overlay {
if isEditing {
Color.black
.opacity(isSelected ? 0 : 0.5)
.clipShape(.circle)
}
}
}
var body: some View { var body: some View {
VStack { VStack {
Button { Button {
action() action()
} label: { } label: {
VStack(alignment: .center) { userImage
ZStack { .hoverEffect(.highlight)
Color.clear
UserProfileImage( Text(user.username)
userID: user.id, .font(.title3)
source: user.profileImageSource( .fontWeight(.semibold)
client: server.client, .foregroundStyle(labelForegroundStyle)
maxWidth: 120 .lineLimit(1)
)
) if showServer {
} Text(server.name)
.aspectRatio(1, contentMode: .fill) .font(.footnote)
.foregroundStyle(.secondary)
} }
} }
.buttonStyle(.card) .buttonStyle(.borderless)
.buttonBorderShape(.circleBackport) .buttonBorderShape(.circle)
// .contextMenu { .contextMenu {
// Button(L10n.delete, role: .destructive) { Button("Delete", role: .destructive) {
// onDelete() onDelete()
// } }
// }
Text(user.username)
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(labelForegroundStyle)
.lineLimit(1)
if showServer {
Text(server.name)
.font(.footnote)
.foregroundStyle(.secondary)
} }
} }
.overlay { .overlay {
if isEditing { if isEditing && isSelected {
ZStack(alignment: .bottomTrailing) { Image(systemName: "checkmark.circle.fill")
Color.black .resizable()
.opacity(isSelected ? 0 : 0.5) .aspectRatio(contentMode: .fit)
.clipShape(.circle) .frame(width: 40, height: 40, alignment: .bottomTrailing)
.symbolRenderingMode(.palette)
if isSelected { .foregroundStyle(accentColor.overlayColor, accentColor)
Image(systemName: "checkmark.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40, alignment: .bottomTrailing)
.symbolRenderingMode(.palette)
.foregroundStyle(accentColor.overlayColor, accentColor)
}
}
} }
} }
} }

View File

@ -17,11 +17,6 @@ import SwiftUI
struct SelectUserView: View { struct SelectUserView: View {
// MARK: - Defaults
@Default(.selectUserServerSelection)
private var serverSelection
// MARK: - User Grid Item Enum // MARK: - User Grid Item Enum
private enum UserGridItem: Hashable { private enum UserGridItem: Hashable {
@ -29,6 +24,16 @@ struct SelectUserView: View {
case addUser case addUser
} }
// MARK: - Defaults
@Default(.selectUserServerSelection)
private var serverSelection
// MARK: - Environment Variable
@Environment(\.colorScheme)
private var colorScheme
// MARK: - State & Environment Objects // MARK: - State & Environment Objects
@EnvironmentObject @EnvironmentObject
@ -46,14 +51,31 @@ struct SelectUserView: View {
@State @State
private var gridItemSize: CGSize = .zero private var gridItemSize: CGSize = .zero
@State @State
private var isEditingUsers: Bool = false
@State
private var padGridItemColumnCount: Int = 1 private var padGridItemColumnCount: Int = 1
@State @State
private var scrollViewOffset: CGFloat = 0 private var scrollViewOffset: CGFloat = 0
@State @State
private var selectedUsers: Set<UserState> = []
@State
private var splashScreenImageSource: ImageSource? = nil private var splashScreenImageSource: ImageSource? = nil
private var users: [UserState] {
gridItems.compactMap { item in
switch item {
case let .user(user, _):
return user
default:
return nil
}
}
}
// MARK: - Dialog States // MARK: - Dialog States
@State
private var isPresentingConfirmDeleteUsers = false
@State @State
private var isPresentingServers: Bool = false private var isPresentingServers: Bool = false
@ -175,17 +197,17 @@ struct SelectUserView: View {
server: server, server: server,
showServer: serverSelection == .all showServer: serverSelection == .all
) { ) {
// if isEditingUsers { if isEditingUsers {
// selectedUsers.toggle(value: user) selectedUsers.toggle(value: user)
// } else { } else {
viewModel.send(.signIn(user, pin: "")) viewModel.send(.signIn(user, pin: ""))
// } }
} onDelete: { } onDelete: {
// selectedUsers.insert(user) selectedUsers.insert(user)
// isPresentingConfirmDeleteUsers = true isPresentingConfirmDeleteUsers = true
} }
// .environment(\.isEditing, isEditingUsers) .environment(\.isEditing, isEditingUsers)
// .environment(\.isSelected, selectedUsers.contains(user)) .environment(\.isSelected, selectedUsers.contains(user))
case .addUser: case .addUser:
AddUserButton( AddUserButton(
serverSelection: $serverSelection, serverSelection: $serverSelection,
@ -193,6 +215,7 @@ struct SelectUserView: View {
) { server in ) { server in
router.route(to: \.userSignIn, server) router.route(to: \.userSignIn, server)
} }
.environment(\.isEnabled, !isEditingUsers)
} }
} }
@ -211,24 +234,34 @@ struct SelectUserView: View {
.frame(height: 100) .frame(height: 100)
gridContentView gridContentView
.focusSection()
} }
.scrollIfLargerThanContainer(padding: 100) .scrollIfLargerThanContainer(padding: 100)
.scrollViewOffset($scrollViewOffset) .scrollViewOffset($scrollViewOffset)
} }
HStack { SelectUserBottomBar(
ServerSelectionMenu( isEditing: $isEditingUsers,
selection: $serverSelection, serverSelection: $serverSelection,
viewModel: viewModel areUsersSelected: selectedUsers.isNotEmpty,
) viewModel: viewModel,
userCount: gridItems.count,
onDelete: {
isPresentingConfirmDeleteUsers = true
}
) {
if selectedUsers.count == users.count {
selectedUsers.removeAll()
} else {
selectedUsers.insert(contentsOf: users)
}
} }
.focusSection()
} }
.animation(.linear(duration: 0.1), value: scrollViewOffset) .animation(.linear(duration: 0.1), value: scrollViewOffset)
.background { .background {
if let splashScreenImageSource { if let splashScreenImageSource {
ZStack { ZStack {
Color.clear
ImageView(splashScreenImageSource) ImageView(splashScreenImageSource)
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.id(splashScreenImageSource) .id(splashScreenImageSource)
@ -278,6 +311,32 @@ struct SelectUserView: View {
} }
} }
// MARK: - Functions
private func didDelete(_ server: ServerState) {
viewModel.send(.getServers)
if case let SelectUserServerSelection.server(id: id) = serverSelection, server.id == id {
if viewModel.servers.keys.count == 1, let first = viewModel.servers.keys.first {
serverSelection = .server(id: first.id)
} else {
serverSelection = .all
}
}
// change splash screen selection if necessary
// selectUserAllServersSplashscreen = serverSelection
}
private func didAppear() {
viewModel.send(.getServers)
splashScreenImageSource = makeSplashScreenImageSource(
serverSelection: serverSelection,
allServersSelection: .all
)
}
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
@ -291,22 +350,11 @@ struct SelectUserView: View {
.ignoresSafeArea() .ignoresSafeArea()
.navigationBarBranding() .navigationBarBranding()
.onAppear { .onAppear {
viewModel.send(.getServers) didAppear()
}
splashScreenImageSource = makeSplashScreenImageSource( .onChange(of: isEditingUsers) { _, newValue in
serverSelection: serverSelection, guard !newValue else { return }
allServersSelection: .all selectedUsers.removeAll()
)
// gridItems = OrderedSet(
// (0 ..< 20)
// .map { i in
// UserState(accessToken: "", id: "\(i)", serverID: "", username: "\(i)")
// }
// .map { u in
// UserGridItem.user(u, server: .init(urls: [], currentURL: URL(string: "/")!, name: "Test", id: "", usersIDs: []))
// }
// )
} }
.onChange(of: serverSelection) { _, newValue in .onChange(of: serverSelection) { _, newValue in
gridItems = makeGridItems(for: newValue) gridItems = makeGridItems(for: newValue)
@ -343,18 +391,23 @@ struct SelectUserView: View {
serverSelection = .server(id: server.id) serverSelection = .server(id: server.id)
} }
.onNotification(.didDeleteServer) { server in .onNotification(.didDeleteServer) { server in
viewModel.send(.getServers) didDelete(server)
}
if case let SelectUserServerSelection.server(id: id) = serverSelection, server.id == id { .confirmationDialog(
if viewModel.servers.keys.count == 1, let first = viewModel.servers.keys.first { Text(L10n.deleteUser),
serverSelection = .server(id: first.id) isPresented: $isPresentingConfirmDeleteUsers,
} else { presenting: selectedUsers
serverSelection = .all ) { selectedUsers in
} Button(L10n.delete, role: .destructive) {
viewModel.send(.deleteUsers(Array(selectedUsers)))
isEditingUsers = false
}
} message: { selectedUsers in
if selectedUsers.count == 1, let first = selectedUsers.first {
Text(L10n.deleteUserSingleConfirmation(first.username))
} else {
Text(L10n.deleteUserMultipleConfirmation(selectedUsers.count))
} }
// change splash screen selection if necessary
// selectUserAllServersSplashscreen = serverSelection
} }
.errorMessage($error) .errorMessage($error)
} }

View File

@ -373,6 +373,7 @@
BD3957792C113EC40078CEF8 /* SubtitleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3957782C113EC40078CEF8 /* SubtitleSection.swift */; }; BD3957792C113EC40078CEF8 /* SubtitleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3957782C113EC40078CEF8 /* SubtitleSection.swift */; };
BD39577C2C113FAA0078CEF8 /* TimestampSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD39577B2C113FAA0078CEF8 /* TimestampSection.swift */; }; BD39577C2C113FAA0078CEF8 /* TimestampSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD39577B2C113FAA0078CEF8 /* TimestampSection.swift */; };
BD39577E2C1140810078CEF8 /* TransitionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD39577D2C1140810078CEF8 /* TransitionSection.swift */; }; BD39577E2C1140810078CEF8 /* TransitionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD39577D2C1140810078CEF8 /* TransitionSection.swift */; };
BDA623532D0D0854009A157F /* SelectUserBottomBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA623522D0D0854009A157F /* SelectUserBottomBar.swift */; };
C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; }; C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; };
C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */; }; C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */; };
C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */; }; C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */; };
@ -1485,6 +1486,7 @@
BD3957782C113EC40078CEF8 /* SubtitleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSection.swift; sourceTree = "<group>"; }; BD3957782C113EC40078CEF8 /* SubtitleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSection.swift; sourceTree = "<group>"; };
BD39577B2C113FAA0078CEF8 /* TimestampSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimestampSection.swift; sourceTree = "<group>"; }; BD39577B2C113FAA0078CEF8 /* TimestampSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimestampSection.swift; sourceTree = "<group>"; };
BD39577D2C1140810078CEF8 /* TransitionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionSection.swift; sourceTree = "<group>"; }; BD39577D2C1140810078CEF8 /* TransitionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionSection.swift; sourceTree = "<group>"; };
BDA623522D0D0854009A157F /* SelectUserBottomBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectUserBottomBar.swift; sourceTree = "<group>"; };
C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTypeLibraryViewModel.swift; sourceTree = "<group>"; }; C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTypeLibraryViewModel.swift; sourceTree = "<group>"; };
C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveSmallPlaybackButton.swift; sourceTree = "<group>"; }; C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveSmallPlaybackButton.swift; sourceTree = "<group>"; };
C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveLargePlaybackButtons.swift; sourceTree = "<group>"; }; C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveLargePlaybackButtons.swift; sourceTree = "<group>"; };
@ -4027,6 +4029,7 @@
E1763A282BF3046A004DF6AB /* AddUserButton.swift */, E1763A282BF3046A004DF6AB /* AddUserButton.swift */,
E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */, E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */,
E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */, E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */,
BDA623522D0D0854009A157F /* SelectUserBottomBar.swift */,
); );
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
@ -5572,6 +5575,7 @@
E1D90D772C051D44000EA787 /* BackPort+ScrollIndicatorVisibility.swift in Sources */, E1D90D772C051D44000EA787 /* BackPort+ScrollIndicatorVisibility.swift in Sources */,
E10231582BCF8AF8009D71FC /* WideChannelGridItem.swift in Sources */, E10231582BCF8AF8009D71FC /* WideChannelGridItem.swift in Sources */,
E15D4F082B1B12C300442DB8 /* Backport.swift in Sources */, E15D4F082B1B12C300442DB8 /* Backport.swift in Sources */,
BDA623532D0D0854009A157F /* SelectUserBottomBar.swift in Sources */,
E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */, E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */,
E1575E9A293E7B1E001665B1 /* Array.swift in Sources */, E1575E9A293E7B1E001665B1 /* Array.swift in Sources */,
E1575E8D293E7B1E001665B1 /* URLComponents.swift in Sources */, E1575E8D293E7B1E001665B1 /* URLComponents.swift in Sources */,