oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1@_spi(Fixtures) import CallsKit
2import SwiftUI
3import LiveKitWebRTC
4
5/// The delegate exists for one reason: Apple hands a device token to
6/// the application delegate and nowhere else, so a pure SwiftUI app has
7/// no way to receive one.
8final class CallsPhoneDelegate: NSObject, UIApplicationDelegate {
9 @MainActor static var model: CallsModel?
10
11 func application(
12 _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data
13 ) {
14 Task { @MainActor in CallsPhoneDelegate.model?.registerDevice(token: token) }
15 }
16
17 func application(
18 _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error
19 ) {
20 // Expected in the simulator and on any build without the push
21 // entitlement. Silent, for the same reason as on the Mac.
22 }
23}
24
25@main struct CallsPhoneApp: App {
26 @UIApplicationDelegateAdaptor(CallsPhoneDelegate.self) private var appDelegate
27 @StateObject private var model = CallsModel()
28 @Environment(\.scenePhase) private var scenePhase
29 var body: some Scene {
30 WindowGroup {
31 #if DEBUG
32 if let level = PhoneCallFixture.rimGallery {
33 WarmRimGallery(level: level).frame(maxWidth: .infinity, maxHeight: .infinity)
34 .background(Color.black).preferredColorScheme(.dark)
35 } else if let fixture = PhoneCallFixture.requested {
36 PhoneCallFixture(fixture: fixture)
37 } else {
38 home
39 }
40 #else
41 home
42 #endif
43 }
44 }
45
46 private var home: some View {
47 PhoneHome(model: model)
48 .onOpenURL { model.openMeetingURL($0) }
49 .onAppear {
50 CallsPhoneDelegate.model = model
51 // UI tests launch with this set so a sign-in kept by an earlier
52 // run, against a harness server long gone, cannot decide what
53 // the next run sees first. The simulator's Keychain outlives the
54 // app being reinstalled.
55 if UserDefaults.standard.bool(forKey: "CallsForgetSignIn") {
56 model.signOut()
57 } else {
58 model.restore()
59 }
60 }
61 .onChange(of: scenePhase) { _, phase in
62 // This milestone is foreground-only. Browser sign-in is kept alive;
63 // an active call leaves cleanly instead of silently sending audio.
64 // The one exception is a call sharing the whole screen, whose
65 // point is the other apps the person is about to go to.
66 if phase == .background && model.joining && !model.call.sharingScreen { model.leave() }
67 UIApplication.shared.isIdleTimerDisabled = phase == .active && model.joining
68 }
69 .onChange(of: model.joining) { _, joining in
70 UIApplication.shared.isIdleTimerDisabled = joining
71 }
72 }
73}
74
75struct PhoneHome: View {
76 @ObservedObject var model: CallsModel
77 @State private var showingSettings = false
78 var body: some View {
79 NavigationStack {
80 Group {
81 if model.joining {
82 PhoneCall(call: model.call, leave: model.leave)
83 } else {
84 CallsLobby(model: model, openSettings: { showingSettings = true })
85 }
86 }.tint(CallsAppearance.accent).navigationTitle("Calls").navigationBarTitleDisplayMode(.inline)
87 .toolbar {
88 // There is no Settings app entry: the gear is where the server
89 // setting lives, reachable from home and never during a call.
90 if !model.joining {
91 ToolbarItem(placement: .topBarTrailing) {
92 Button("Settings", systemImage: "gearshape") { showingSettings = true }
93 .accessibilityIdentifier("settings")
94 }
95 }
96 }
97 .sheet(isPresented: $showingSettings) {
98 ServerSettingsSheet(model: model) { showingSettings = false }
99 .presentationDetents([.medium, .large])
100 }
101 }
102 }
103}
104
105struct PhoneCall: View {
106 @ObservedObject var call: Call
107 let leave: () -> Void
108 @State var showingChat = false
109 @State var showingDevices = false
110 @State var showingMore = false
111 /// What the controls sheet was closed to do, done once it has gone:
112 /// one sheet at a time.
113 @State var afterMore: MoreAction?
114 @State var readChatCount = 0
115 @ObservedObject var arrangement: CallArrangement
116 @ObservedObject var speakerNow: SpeakerNow
117 /// A tile being dragged to a new place, and where every tile is.
118 @State var drag: TileDrag?
119 @State var tileFrames: [String: CGRect] = [:]
120 @State var dismissedNotices: Set<String> = []
121 @State var pipDrag: CGSize = .zero
122 @StateObject var draft = ChatDraft()
123 @StateObject var screenShare = PhoneScreenShare()
124 @StateObject var chrome: ControlsVisibilityModel
125 @AppStorage(CallLayoutStyle.key) private var layoutChoice = CallLayoutStyle.defaultStyle.rawValue
126 @AppStorage(PiPCorner.key) var pipCorner = PiPCorner.bottomTrailing.rawValue
127 /// A microphone button beside the controls menu, off by default: the
128 /// chosen layout is the menu and End call alone. Set from the command
129 /// line (`-CallsMicBesideMenu YES`) to try the other.
130 @AppStorage("CallsMicBesideMenu") var micBesideMenu = false
131 @Environment(\.accessibilityReduceMotion) var reduceMotion
132 @Environment(\.accessibilityVoiceOverEnabled) var voiceOver
133 @Environment(\.fixtureBackdrop) var fixtureBackdrop
134 private let style: CallLayoutStyle?
135
136 init(
137 call: Call, leave: @escaping () -> Void, style: CallLayoutStyle? = nil,
138 controlsShown: Bool? = nil, showingMore: Bool = false, grid: Bool? = nil
139 ) {
140 self.call = call
141 self.leave = leave
142 self.style = style
143 _showingMore = State(initialValue: showingMore)
144 if let grid { call.arrangement.show(grid ? .grid : .speaker) }
145 arrangement = call.arrangement
146 speakerNow = call.levels.speaker
147 _chrome = StateObject(wrappedValue: ControlsVisibilityModel(input: .touch, pinned: controlsShown))
148 }
149
150 var layout: CallLayoutStyle { style ?? CallLayoutStyle(rawValue: layoutChoice) ?? .defaultStyle }
151
152 var grid: Bool { arrangement.layout == .grid }
153 /// Everyone else, in this viewer's order.
154 var peers: [RosterPeer] {
155 let roster = call.state?.peers ?? []
156 return arrangement.others.compactMap { id in roster.first { $0.peer == id } }
157 }
158 /// What Speaker view's stage shows, and the filmstrip under it.
159 var focused: StageItem? { arrangement.stage(active: speakerNow.peer, screens: call.screens) }
160 var filmstrip: [StageItem] { arrangement.filmstrip(stage: focused, screens: call.screens) }
161 /// You float over the stage only when you would be all the filmstrip
162 /// holds (see Stage.selfFloats).
163 var selfFloats: Bool { Stage.selfFloats(filmstrip: filmstrip, me: arrangement.me) }
164 var gridItems: [StageItem] {
165 call.screens.reversed().map(StageItem.screen) + arrangement.people.map(StageItem.camera)
166 }
167
168 /// A tile for anything the call can show, as it is seen now.
169 func phoneTile(
170 _ item: StageItem, cornerRadius: CGFloat = 18, compact: Bool = false,
171 labelInsets: EdgeInsets = EdgeInsets(), backdrop: Color = .black, onStage: Bool = false,
172 iconOnly: Bool = false
173 ) -> PhoneVideoTile {
174 let pinned = onStage && arrangement.pinned == item
175 switch item {
176 case .screen(let peer):
177 return PhoneVideoTile(
178 track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), screen: true,
179 cornerRadius: cornerRadius, compact: compact, labelInsets: labelInsets, backdrop: backdrop,
180 onStage: onStage, pinned: pinned, iconOnly: iconOnly)
181 case .camera(let peer) where peer == arrangement.me:
182 return PhoneVideoTile(
183 track: call.localTrack, label: "You", muted: call.muted, mirrored: true,
184 cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: cornerRadius,
185 compact: compact, labelInsets: labelInsets, backdrop: backdrop, audio: call.selfAudio,
186 onStage: onStage, pinned: pinned, iconOnly: iconOnly)
187 case .camera(let peer):
188 return PhoneVideoTile(
189 track: call.remoteTracks[peer], label: call.name(of: peer), muted: call.isMuted(peer),
190 cameraOff: call.isCameraOff(peer), cornerRadius: cornerRadius, compact: compact,
191 labelInsets: labelInsets, backdrop: backdrop, audio: call.tileAudio(peer),
192 onStage: onStage, pinned: pinned, iconOnly: iconOnly)
193 }
194 }
195
196 func itemTitle(_ item: StageItem) -> String {
197 switch item {
198 case .screen(let peer): screenCaption(call.name(of: peer))
199 case .camera(let peer): peer == arrangement.me ? "You" : call.name(of: peer)
200 }
201 }
202
203 /// A tile of Grid or the filmstrip: a long press picks it up to move
204 /// it, a tap pins it (the filmstrip), and VoiceOver has both as actions.
205 func arrangeable(_ item: StageItem, tapToPin: Bool) -> ArrangeableTile {
206 ArrangeableTile(
207 item: item, title: itemTitle(item), arrangement: arrangement, drag: $drag,
208 frames: tileFrames, tapToPin: tapToPin, touch: true, reduceMotion: reduceMotion,
209 fixtureLift: item == .camera(arrangement.fixtureLift ?? "") ? CGSize(width: 24, height: -30) : nil)
210 }
211
212 var body: some View {
213 Group {
214 if layout == .classic {
215 classic
216 } else {
217 fullScreen
218 }
219 }
220 .overlay(alignment: .bottom) {
221 CallToast(call: call).padding(.bottom, 110)
222 }
223 .background {
224 // ⌘⇧I on an iPad's keyboard, as on the Mac: the invite link to the
225 // clipboard. A key command with no button to show for it.
226 Button(InviteLinkCopy.title) { Task { await call.copyInviteLink() } }
227 .keyboardShortcut(InviteLinkCopy.shortcut)
228 .opacity(0).frame(width: 0, height: 0).accessibilityHidden(true)
229 }
230 .task { await call.watchAudio() }
231 .onAppear { screenShare.listen(for: call, leave: leave) }
232 .onDisappear {
233 screenShare.stop()
234 call.stopSharingScreen()
235 }
236 .sheet(isPresented: $showingDevices) {
237 NavigationStack {
238 ScrollView { DevicePanel(call: call).frame(maxWidth: .infinity, alignment: .leading) }
239 .navigationTitle("Devices").navigationBarTitleDisplayMode(.inline)
240 .toolbar {
241 ToolbarItem(placement: .confirmationAction) {
242 Button("Done") { showingDevices = false }
243 }
244 }
245 }.presentationDetents([.medium, .large])
246 }
247 .sheet(isPresented: $showingChat, onDismiss: { readChatCount = call.receivedChatCount }) {
248 NavigationStack {
249 ChatView(call: call, draft: draft).padding()
250 .navigationTitle("Chat").navigationBarTitleDisplayMode(.inline)
251 .toolbar {
252 ToolbarItem(placement: .confirmationAction) { Button("Done") { showingChat = false } }
253 }
254 }.presentationDetents([.medium, .large])
255 }
256 .onChange(of: call.receivedChatCount) { _, count in
257 if showingChat { readChatCount = count }
258 }
259 .reportProblemSheet(call)
260 }
261
262 /// The call as it was before the full-screen layout: title and stage
263 /// in the page, controls always showing along the bottom.
264 private var classic: some View {
265 VStack(spacing: 12) {
266 HStack {
267 VStack(alignment: .leading, spacing: 4) {
268 Text(call.title).font(.headline).lineLimit(2)
269 Text(call.status).font(.caption).foregroundStyle(.secondary)
270 if call.sharingScreen {
271 Label("You are sharing your screen", systemImage: "rectangle.on.rectangle")
272 .font(.caption.bold()).foregroundStyle(.orange)
273 }
274 }
275 Spacer()
276 Menu {
277 LayoutChoices(call: call, arrangement: arrangement, title: itemTitle)
278 } label: {
279 Label(
280 "Video layout", systemImage: arrangement.layout.symbol
281 )
282 .labelStyle(.iconOnly).frame(width: 44, height: 44)
283 }.accessibilityIdentifier("videoLayout")
284 }
285 if call.floor {
286 Text("Direct connections only. Some networks may be unable to connect.")
287 .font(.footnote).foregroundStyle(.secondary)
288 }
289 if grid {
290 ScrollView {
291 LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) {
292 ForEach(gridItems, id: \.self) { item in
293 phoneTile(item)
294 .aspectRatio(4 / 3, contentMode: .fit)
295 .modifier(arrangeable(item, tapToPin: false))
296 }
297 }
298 }
299 .coordinateSpace(name: arrangeSpace)
300 .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 }
301 } else {
302 GeometryReader { geometry in
303 ZStack(alignment: .bottomTrailing) {
304 if let focused {
305 phoneTile(focused, onStage: true)
306 } else {
307 ContentUnavailableView(
308 "Waiting for others", systemImage: "person.2",
309 description: Text("Others will appear here when they join.")
310 )
311 .frame(maxWidth: .infinity, maxHeight: .infinity)
312 .background(CallsAppearance.background, in: RoundedRectangle(cornerRadius: 18))
313 }
314 phoneTile(.camera(arrangement.me ?? ""))
315 .frame(
316 width: min(160, geometry.size.width * 0.32),
317 height: min(120, geometry.size.height * 0.28)
318 )
319 .shadow(color: .black.opacity(0.25), radius: 6, y: 2).padding(12)
320 }
321 }
322 }
323 if !(call.state?.waiting.isEmpty ?? true) {
324 ScrollView {
325 ForEach(call.state?.waiting ?? []) { person in
326 HStack {
327 Text("\(person.name) is waiting")
328 Spacer()
329 Button("Let in") { call.admit(person.peer) }
330 }
331 }
332 }.frame(maxHeight: 90)
333 }
334 if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } }
335 if call.state?.phase == "ended" { ReportProblemButton { call.reportProblem() }.buttonStyle(.bordered) }
336 }.padding(.horizontal, 16).padding(.top, 8)
337 .safeAreaInset(edge: .bottom) {
338 HStack(spacing: 14) {
339 Button(
340 call.muted ? "Unmute microphone" : "Mute microphone",
341 glyph: call.muted ? "mic.slash.fill" : "mic.fill"
342 ) { call.mute() }
343 // Space on a hardware keyboard (iPad, mostly). It is a
344 // UIKeyCommand without wantsPriorityOverSystemBehavior, so a
345 // text field being typed in and a control with keyboard focus
346 // keep their Space; only otherwise does it mute.
347 .keyboardShortcut(.space, modifiers: [])
348 .buttonStyle(
349 CallActionStyle(
350 color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent,
351 emphasized: call.muted))
352 Button(
353 call.cameraOff ? "Turn camera on" : "Turn camera off",
354 glyph: call.cameraOff ? "video.slash.fill" : "video.fill"
355 ) { call.toggleCamera() }
356 .buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff))
357 if call.sharingScreen {
358 Button("Stop sharing your screen", glyph: "rectangle.on.rectangle.slash") {
359 call.stopSharingScreen()
360 }
361 .buttonStyle(CallActionStyle(color: .orange, emphasized: true))
362 .accessibilityIdentifier("shareScreen")
363 } else {
364 BroadcastButton().frame(width: 44, height: 44)
365 .disabled(call.state?.phase != "admitted")
366 .accessibilityIdentifier("shareScreen")
367 }
368 Button("Devices", glyph: "slider.horizontal.3") { showingDevices = true }
369 .buttonStyle(CallActionStyle(color: .teal))
370 .accessibilityIdentifier("devices")
371 MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo))
372 Spacer(minLength: 0)
373 Button(chatButtonTitle, glyph: "bubble.left.and.bubble.right.fill") {
374 readChatCount = call.receivedChatCount
375 showingChat = true
376 }.buttonStyle(CallActionStyle(color: .purple)).accessibilityIdentifier("openChat")
377 .overlay(alignment: .topTrailing) {
378 if unread > 0 {
379 Text("\(unread)").font(.caption.bold()).foregroundStyle(.white)
380 .padding(4).background(.purple, in: Capsule()).accessibilityHidden(true)
381 }
382 }
383 Button("Leave meeting", glyph: "phone.down.fill", role: .destructive, action: leave)
384 .buttonStyle(CallActionStyle(color: .red, emphasized: true))
385 .accessibilityIdentifier("leave")
386 }.padding(16).background(.bar)
387 }
388 }
389 var unread: Int { max(0, call.receivedChatCount - readChatCount) }
390 var chatButtonTitle: String { unread == 0 ? "Chat" : "Chat (\(unread) new)" }
391}
392
393struct PhoneVideoTile: View {
394 let track: RTCVideoTrack?
395 let label: String
396 /// §15.19: the peer has said it is muted. False for unknown.
397 var muted = false
398 var mirrored = false
399 var cameraOff = false
400 /// A shared screen (§5.4), shown whole rather than cropped: on a phone
401 /// more than anywhere, cropping a laptop's screen to a portrait tile
402 /// would leave the middle third of it.
403 var screen = false
404 /// Your own tile: what the camera is doing, in place of a picture that
405 /// is not arriving (see `Call.selfPlaceholder`). Nil shows the video.
406 var placeholder: String? = nil
407 /// The full-screen call's look: as rounded as `cornerRadius` says
408 /// (square to the screen's edges for the stage), a smaller name with the
409 /// muted mark inside it, kept `labelInsets` clear of the safe area and
410 /// the controls.
411 var cornerRadius: CGFloat = 18
412 var compact = false
413 var labelInsets = EdgeInsets()
414 /// Behind a picture that is not there: a shade lighter than the stage
415 /// for a tile among others, so it reads as a tile.
416 var backdrop = Color.black
417 /// The sound on this tile (spec §15.20): your microphone's glow, or a
418 /// remote person's with "No sound from" and their notices. Nil for a
419 /// screen.
420 var audio: TileAudio? = nil
421 /// Speaker view's stage: VoiceOver hears "On stage: Ana", and whether
422 /// it was pinned there.
423 var onStage = false
424 var pinned = false
425 /// A filmstrip thumbnail: the symbol alone for a missing picture.
426 var iconOnly = false
427 /// The height of the name label and what is over it, which the
428 /// placeholder keeps clear of.
429 @State private var labelBlock: CGFloat = 0
430 @Environment(\.fixtureStagePicture) private var fixturePicture
431 var body: some View {
432 TileAudioReader(audio) { info in tile(info) }
433 }
434 private func tile(_ info: TileAudioInfo?) -> some View {
435 ZStack {
436 backdrop
437 if let fixturePicture, onStage, track == nil, !cameraOff, placeholder == nil {
438 FixtureVideo(picture: fixturePicture, screen: screen)
439 } else if let track, !cameraOff, placeholder == nil {
440 // A screen on the stage is fitted whole; around it, the same
441 // picture soft and dimmed rather than black bars.
442 if screen, onStage { ScreenBackdrop(track: track) }
443 PhoneVideo(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1)
444 } else {
445 TilePlaceholder(
446 symbol: cameraOff
447 ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle",
448 words: cameraOff || iconOnly ? nil : placeholder, wordsFont: .caption, spacing: 6,
449 reserve: labelBlock)
450 }
451 }
452 .overlay(alignment: .bottomLeading) {
453 VStack(alignment: .leading, spacing: 4) {
454 TileAudioNotices(info)
455 HStack(spacing: 5) {
456 TileSpeakingName(label: label, muted: muted, compact: compact, glow: audio?.glow)
457 TileAudioStatus(info)
458 }
459 }
460 .padding(compact ? 6 : 8).padding(compact ? labelInsets : EdgeInsets())
461 // Room past the label's end for the rim's outer breath, when a long
462 // name has it truncating.
463 .padding(.trailing, 6)
464 .tileLabelBlock($labelBlock)
465 }
466 .overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(8) } }
467 .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
468 .accessibilityElement(children: .ignore)
469 .accessibilityLabel(stageLabel(tileAccessibilityLabel(label, muted: muted, audio: info), onStage: onStage, pinned: pinned))
470 .accessibilityHint(info?.detail ?? "")
471 .accessibilityValue(
472 cameraOff
473 ? "Camera off"
474 : placeholder ?? (track == nil ? (screen ? "Waiting for the screen" : "Waiting for video") : "Video on"))
475 }
476}
477struct PhoneVideo: UIViewRepresentable {
478 let track: RTCVideoTrack
479 var fit = false
480 func makeCoordinator() -> Coordinator { Coordinator() }
481 func makeUIView(context: Context) -> PlacedVideoView {
482 let view = PlacedVideoView()
483 view.fit = fit
484 context.coordinator.attach(track, to: view.renderer)
485 return view
486 }
487 func updateUIView(_ view: PlacedVideoView, context: Context) {
488 view.fit = fit
489 context.coordinator.attach(track, to: view.renderer)
490 }
491 static func dismantleUIView(_ view: PlacedVideoView, coordinator: Coordinator) {
492 coordinator.track?.remove(view.renderer)
493 }
494 final class Coordinator {
495 var track: RTCVideoTrack?
496 func attach(_ next: RTCVideoTrack, to view: RTCMTLVideoView) {
497 if track !== next {
498 track?.remove(view)
499 track = next
500 next.add(view)
501 }
502 }
503 }
504}
505
506/// The renderer at the size and place `VideoFrame` gives it: a camera
507/// filling the tile with more of a crop taken from the bottom than the
508/// top, so heads are not cut; a screen fitted whole. The container clips.
509final class PlacedVideoView: UIView, RTCVideoViewDelegate {
510 let renderer = RTCMTLVideoView(frame: .zero)
511 private var videoSize = CGSize(width: 16, height: 9)
512 var fit = false {
513 didSet { if fit != oldValue { setNeedsLayout() } }
514 }
515 override init(frame: CGRect) {
516 super.init(frame: frame)
517 clipsToBounds = true
518 isUserInteractionEnabled = false
519 renderer.videoContentMode = .scaleToFill
520 renderer.delegate = self
521 addSubview(renderer)
522 }
523 required init?(coder: NSCoder) { nil }
524 nonisolated func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
525 DispatchQueue.main.async { [weak self] in
526 guard let self, size.width > 0, size.height > 0 else { return }
527 self.videoSize = size
528 self.setNeedsLayout()
529 }
530 }
531 override func layoutSubviews() {
532 super.layoutSubviews()
533 renderer.frame = VideoFrame.place(video: videoSize, in: bounds.size, fit: fit)
534 }
535}
536
537/// Speaker or Grid, and what is pinned to Speaker view's stage: the
538/// iPhone's layout menu, in the classic call and the controls sheet.
539struct LayoutChoices: View {
540 @ObservedObject var call: Call
541 @ObservedObject var arrangement: CallArrangement
542 let title: (StageItem) -> String
543
544 var body: some View {
545 Picker(
546 "Video layout", selection: Binding(get: { arrangement.layout }, set: { arrangement.choose($0) })
547 ) {
548 ForEach(VideoLayout.allCases) { layout in
549 Label(layout.title, systemImage: layout.symbol).tag(layout)
550 }
551 }
552 if arrangement.people.count > 2 || !call.screens.isEmpty {
553 Section("On the stage") {
554 Button {
555 arrangement.pin(nil)
556 } label: {
557 Label("Whoever is speaking", systemImage: arrangement.pinned == nil ? "checkmark" : "waveform")
558 }
559 ForEach(call.screens.reversed(), id: \.self) { peer in choice(.screen(peer)) }
560 ForEach(arrangement.others, id: \.self) { peer in choice(.camera(peer)) }
561 }
562 }
563 }
564
565 private func choice(_ item: StageItem) -> some View {
566 Button {
567 arrangement.pin(item)
568 } label: {
569 Label(title(item), systemImage: arrangement.pinned == item ? "checkmark" : "pin")
570 }
571 }
572}
573