crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

PuzzleView.swift (25191B)


      1 import SwiftUI
      2 
      3 enum RevealScope {
      4     case square
      5     case word
      6     case puzzle
      7 
      8     var title: String {
      9         switch self {
     10         case .square: "Reveal Square?"
     11         case .word: "Reveal Word?"
     12         case .puzzle: "Reveal Puzzle?"
     13         }
     14     }
     15 
     16     var message: String {
     17         switch self {
     18         case .square: "This will reveal the current square."
     19         case .word: "This will reveal the current word."
     20         case .puzzle: "This will reveal the entire puzzle and mark it complete."
     21         }
     22     }
     23 }
     24 
     25 struct PuzzleView: View {
     26     @Bindable var session: PlayerSession
     27     var shareController: ShareController? = nil
     28     let roster: PlayerRoster
     29     var onComplete: ((_ notifyPeers: Bool) -> Void)? = nil
     30     var onResign: (() throws -> Void)? = nil
     31     var onDelete: (() throws -> Void)? = nil
     32     /// Sends a broadcast nudge to the other players. `nil` for solo/test
     33     /// sessions, which hides the menu button.
     34     var onNudge: (() async -> Void)? = nil
     35     /// When the next nudge becomes allowed (the send cooldown's end), or `nil`
     36     /// if one is allowed right now. A session without nudging wired up hides the
     37     /// button regardless (see `onNudge`).
     38     var nudgeReadyAt: () -> Date? = { nil }
     39     /// Loads the finished game's merged journal for the finish-banner replay
     40     /// scrubber. Defaults to `.unavailable` so previews/tests need not wire it.
     41     var loadReplay: () async -> JournalReplayResult = { .unavailable }
     42     /// Cells a peer filled or cleared since this player last viewed the puzzle,
     43     /// mapped to the writing author. Read once on the open arm beat. Defaults to
     44     /// empty so previews/tests need not wire it.
     45     var loadRecentChanges: () -> [GridPosition: String] = { [:] }
     46     /// Stamps this game's last-viewed timestamp (device-local). Called when the
     47     /// away-change borders are acknowledged. Defaults to a no-op.
     48     var markPuzzleViewed: () -> Void = {}
     49     @Environment(InputMonitor.self) private var inputMonitor
     50     @Environment(PlayerPreferences.self) private var preferences
     51     @Environment(AnnouncementCenter.self) private var announcements
     52     @Environment(\.dismiss) private var dismiss
     53     @State private var isRenaming = false
     54     @State private var renameDraft = ""
     55     @State private var showErrorsAlert = false
     56     @State private var isConfirmingResign = false
     57     @State private var isConfirmingDelete = false
     58     @State private var isConfirmingLeave = false
     59     @State private var isConfirmingReveal = false
     60     @State private var pendingRevealScope: RevealScope = .square
     61     @State private var leaveError: String?
     62     @State private var destructiveActionError: String?
     63     @State private var isShowingShareSheet = false
     64     @State private var hasSolved = false
     65     @State private var replay = ReplayControls()
     66     @State private var padLayout: PadLayout?
     67     /// The shared open "arm" beat: flips a moment after open so the banner and
     68     /// the "changed while you were away" borders reveal together.
     69     @State private var isArmed = false
     70     /// Drives the system keyboard for rebus entry. Bound to `isRebusActive`:
     71     /// focusing the rebus field raises the keyboard, and losing focus (e.g. the
     72     /// player swipes the keyboard away) commits the buffer.
     73     @FocusState private var isRebusFieldFocused: Bool
     74     @Environment(\.engagementStatus) private var engagementStatus
     75 
     76     private enum PadLayout {
     77         case landscape
     78         case portrait
     79     }
     80 
     81     private func swatchImage(for color: PlayerColor) -> Image {
     82         let tint = UIColor(color.tint)
     83         let base = UIImage(systemName: "circle.fill") ?? UIImage()
     84         return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal))
     85     }
     86 
     87     private struct TitleParts {
     88         let title: String
     89         let subtitle: String?
     90     }
     91 
     92     private var titleParts: TitleParts {
     93         let title = session.puzzle.title
     94         let formattedDate = session.puzzle.date?.formatted(date: .long, time: .omitted)
     95         let subtitle: String?
     96         if let publisher = session.puzzle.publisher, let formattedDate {
     97             subtitle = "\(publisher) · \(formattedDate)"
     98         } else if let publisher = session.puzzle.publisher {
     99             subtitle = publisher
    100         } else {
    101             subtitle = formattedDate
    102         }
    103         return TitleParts(title: title, subtitle: subtitle)
    104     }
    105 
    106     // Latched completion counts as solved for the read-only presentation
    107     // (hides the keyboard, shows the finish panel, disables the controls) even
    108     // when the locally merged grid drifted and no longer reads `.solved`.
    109     private var isSolved: Bool { hasSolved || session.mutator.isCompleted }
    110 
    111     /// Whether a sticky, input-blocking announcement (currently only
    112     /// access revocation) is showing for this game. Greys out the custom
    113     /// keyboard and makes the hardware-key handler a no-op.
    114     private var isInputBlocked: Bool {
    115         announcements.isInputBlocked(forGame: session.mutator.gameID)
    116     }
    117 
    118     private var shouldAutoRevealScoreboard: Bool {
    119         onNudge != nil && !isSolved && roster.entries.contains(where: { !$0.isLocal })
    120     }
    121 
    122     var body: some View {
    123         Group {
    124             switch padLayout {
    125             case .landscape:
    126                 landscapePadLayout
    127             case .portrait:
    128                 portraitPadLayout
    129             case .none:
    130                 phoneLayout
    131             }
    132         }
    133         .background(Color(.systemBackground))
    134         .background {
    135             // Yields first responder during rebus so the focused rebus field
    136             // owns input (including hardware keys) and the system keyboard rises.
    137             HardwareKeyboardInputView(
    138                 onPress: handleHardwareKeyboardEvent,
    139                 isActive: !session.isRebusActive
    140             )
    141             .frame(width: 0, height: 0)
    142             .allowsHitTesting(false)
    143         }
    144         .ignoresSafeArea(.keyboard)
    145         .onChange(of: session.isRebusActive) { _, active in
    146             isRebusFieldFocused = active
    147         }
    148         .onChange(of: isRebusFieldFocused) { _, focused in
    149             // The player dismissed the keyboard (swipe-down / hardware Esc):
    150             // treat it as a commit, matching the scrim tap.
    151             if !focused, session.isRebusActive {
    152                 session.commitRebus()
    153             }
    154         }
    155         .modifier(PuzzleToolbarModifier(
    156             session: session,
    157             roster: roster,
    158             shareController: shareController,
    159             isSolved: isSolved,
    160             canResign: onResign != nil,
    161             canDelete: onDelete != nil,
    162             onNudge: onNudge,
    163             nudgeReadyAt: nudgeReadyAt,
    164             isRenaming: $isRenaming,
    165             renameDraft: $renameDraft,
    166             isConfirmingResign: $isConfirmingResign,
    167             isConfirmingDelete: $isConfirmingDelete,
    168             isConfirmingLeave: $isConfirmingLeave,
    169             isConfirmingReveal: $isConfirmingReveal,
    170             pendingRevealScope: $pendingRevealScope,
    171             isShowingShareSheet: $isShowingShareSheet
    172         ))
    173         .modifier(PuzzleLifecycleModifier(
    174             session: session,
    175             roster: roster,
    176             hasSolved: $hasSolved,
    177             onCompletionEvent: handleCompletionEvent,
    178             onSolvedOnAppear: {
    179                 onComplete?(false)
    180             }
    181         ))
    182         .modifier(PuzzlePresentationModifier(
    183             session: session,
    184             shareController: shareController,
    185             isRenaming: $isRenaming,
    186             renameDraft: $renameDraft,
    187             showErrorsAlert: $showErrorsAlert,
    188             isConfirmingResign: $isConfirmingResign,
    189             isConfirmingDelete: $isConfirmingDelete,
    190             isConfirmingLeave: $isConfirmingLeave,
    191             isConfirmingReveal: $isConfirmingReveal,
    192             pendingRevealScope: $pendingRevealScope,
    193             leaveError: $leaveError,
    194             destructiveActionError: $destructiveActionError,
    195             isShowingShareSheet: $isShowingShareSheet,
    196             performResign: performResign,
    197             performDelete: performDelete,
    198             leaveSharedGame: leaveSharedGame
    199         ))
    200         // Surfaces the puzzle's actions to the app-level menu so the hold-⌘
    201         // shortcut overlay lists them; reveal routes through the same
    202         // confirmation alert the toolbar uses.
    203         .focusedSceneValue(\.puzzleActions, PuzzleActionTarget(
    204             session: session,
    205             isEnabled: !isSolved && !isInputBlocked,
    206             requestReveal: { scope in
    207                 pendingRevealScope = scope
    208                 isConfirmingReveal = true
    209             }
    210         ))
    211         .onGeometryChange(for: CGSize.self) { proxy in
    212             proxy.size
    213         } action: { newSize in
    214             updateLayoutTrait(for: newSize)
    215         }
    216         .onAppear {
    217             session.onRecentChangesAcknowledged = markPuzzleViewed
    218         }
    219         .task(id: session.mutator.gameID) {
    220             // The shared open beat. A short hold lets the puzzle settle and the
    221             // on-open sync land; then we arm the banner and capture — once —
    222             // which cells a peer changed while we were away, so both reveal
    223             // together. Moves that arrive after this are live activity (peer
    224             // cursor tints), not part of the away-summary.
    225             isArmed = false
    226             try? await Task.sleep(for: .milliseconds(750))
    227             isArmed = true
    228             if session.mutator.isShared {
    229                 session.recentChanges = loadRecentChanges()
    230             }
    231         }
    232     }
    233 
    234     private var phoneLayout: some View {
    235         VStack(spacing: 0) {
    236             puzzleArea()
    237             controlsArea(showClueBar: true)
    238         }
    239     }
    240 
    241     private var landscapePadLayout: some View {
    242         VStack(spacing: 0) {
    243             HStack(spacing: 0) {
    244                 VStack(spacing: 0) {
    245                     if !isSolved {
    246                         PuzzleScoreboard(
    247                             session: session,
    248                             roster: roster,
    249                             onNudge: onNudge,
    250                             nudgeReadyAt: nudgeReadyAt
    251                         )
    252 
    253                         Divider()
    254                     }
    255 
    256                     ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame)
    257                 }
    258                     .frame(minWidth: 300, idealWidth: 360, maxWidth: 420)
    259                     .background(Color(.secondarySystemBackground))
    260 
    261                 Divider()
    262                     .ignoresSafeArea(edges: .top)
    263 
    264                 puzzleArea(bottomInset: 12)
    265                     .frame(maxWidth: .infinity, maxHeight: .infinity)
    266             }
    267             .frame(maxWidth: .infinity, maxHeight: .infinity)
    268 
    269             controlsArea(showClueBar: false)
    270         }
    271     }
    272 
    273     private var portraitPadLayout: some View {
    274         VStack(spacing: 0) {
    275             WeightedVStack(weights: [3, 1]) {
    276                 puzzleArea(bottomInset: 12)
    277                     .frame(maxWidth: .infinity, maxHeight: .infinity)
    278 
    279                 VStack(spacing: 0) {
    280                     Divider()
    281 
    282                     HStack(alignment: .top, spacing: 0) {
    283                         if !isSolved {
    284                             PuzzleScoreboard(
    285                                 session: session,
    286                                 roster: roster,
    287                                 onNudge: onNudge,
    288                                 nudgeReadyAt: nudgeReadyAt
    289                             )
    290                                 .frame(minWidth: 240, idealWidth: 280, maxWidth: 320)
    291 
    292                             Divider()
    293                         }
    294 
    295                         ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame)
    296                             .frame(maxWidth: .infinity, maxHeight: .infinity)
    297                     }
    298                     .background(Color(.secondarySystemBackground))
    299                 }
    300             }
    301             .frame(maxWidth: .infinity, maxHeight: .infinity)
    302 
    303             controlsArea(showClueBar: false)
    304         }
    305     }
    306 
    307     private func updateLayoutTrait(for size: CGSize) {
    308         guard UIDevice.current.userInterfaceIdiom == .pad, size != .zero else {
    309             padLayout = nil
    310             return
    311         }
    312         padLayout = size.width > size.height ? .landscape : .portrait
    313     }
    314 
    315     private func performResign() {
    316         do {
    317             try onResign?()
    318             dismiss()
    319         } catch {
    320             destructiveActionError = String(describing: error)
    321         }
    322     }
    323 
    324     private func performDelete() {
    325         do {
    326             try onDelete?()
    327             dismiss()
    328         } catch {
    329             destructiveActionError = String(describing: error)
    330         }
    331     }
    332 
    333     private func handleCompletionEvent(_ event: PlayerSession.CompletionEvent) {
    334         switch (event.origin, event.state) {
    335         case (_, .incomplete):
    336             break
    337         case (.observed, .filledWithErrors):
    338             // A collaborator's wrong entry must not interrupt the local solver.
    339             break
    340         case (.local, .filledWithErrors):
    341             showErrorsAlert = true
    342         case (.local, .solved):
    343             guard !hasSolved else { return }
    344             hasSolved = true
    345             if session.isPencilMode {
    346                 session.togglePencil()
    347             }
    348             Task { @MainActor in
    349                 onComplete?(true)
    350             }
    351         case (.observed, .solved):
    352             guard !hasSolved else { return }
    353             hasSolved = true
    354             onComplete?(false)
    355         }
    356     }
    357 
    358     private func puzzleArea(bottomInset: CGFloat = 0) -> some View {
    359         ZStack {
    360             VStack(spacing: 4) {
    361                 PuzzleHeader(
    362                     session: session,
    363                     roster: roster,
    364                     title: titleParts.title,
    365                     subtitle: titleParts.subtitle,
    366                     showsScoreboard: padLayout == nil,
    367                     shouldAutoRevealScoreboard: shouldAutoRevealScoreboard,
    368                     gameID: session.mutator.gameID,
    369                     isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true,
    370                     onNudge: onNudge,
    371                     nudgeReadyAt: nudgeReadyAt,
    372                     isArmed: isArmed
    373                 )
    374                 GridView(
    375                     session: session,
    376                     roster: roster,
    377                     showsSharedAnnotations: session.mutator.isShared,
    378                     showsPeerCursors: !isSolved,
    379                     replayFrame: replay.frame
    380                 )
    381             }
    382             .frame(maxWidth: .infinity, maxHeight: .infinity)
    383             .padding(.top, -8)
    384             // Keep the gap above the clue list inside the ZStack so the rebus
    385             // scrim (a sibling below) covers it too, rather than leaving a strip
    386             // of background showing between the grid and the clue list.
    387             .padding(.bottom, bottomInset)
    388 
    389             if session.isRebusActive {
    390                 Color.black.opacity(0.35)
    391                     // Ignore the bottom edge too: with a hardware keyboard there
    392                     // is no software keyboard covering the bottom safe area, so a
    393                     // top-only scrim leaves an un-dimmed strip at the screen edge.
    394                     .ignoresSafeArea(edges: [.top, .bottom])
    395                     .contentShape(Rectangle())
    396                     .onTapGesture {
    397                         session.commitRebus()
    398                     }
    399                 // No swallow gesture here: the card's opaque background already
    400                 // blocks taps from reaching the commit scrim beneath, and adding
    401                 // a tap gesture in front would steal the field's own taps,
    402                 // limiting the caret to the start/end of the buffer.
    403                 RebusModal(text: $session.rebusBuffer, isFocused: $isRebusFieldFocused) {
    404                     session.commitRebus()
    405                 }
    406                 .padding(.horizontal)
    407             }
    408         }
    409     }
    410 
    411     private func controlsArea(showClueBar: Bool) -> some View {
    412         VStack(spacing: 0) {
    413             if showClueBar {
    414                 ClueBarSlot(session: session, replayFrame: replay.frame)
    415             }
    416             controlsPanel
    417                 .frame(height: controlsPanelHeight)
    418         }
    419     }
    420 
    421     private var controlsPanel: some View {
    422         ZStack(alignment: .top) {
    423             if isSolved {
    424                 ControlsView(height: controlsPanelHeight) {
    425                     SuccessPanel(
    426                         session: session,
    427                         roster: roster,
    428                         replay: replay,
    429                         loadReplay: loadReplay
    430                     )
    431                 }
    432                 .transition(.move(edge: .bottom))
    433             } else if showsCustomKeyboard {
    434                 ControlsView(height: controlsPanelHeight) {
    435                     KeyboardView(session: session, showsNavigationKeys: padLayout != nil)
    436                         .opacity(isInputBlocked ? 0.4 : 1)
    437                         .allowsHitTesting(!isInputBlocked)
    438                         .animation(.easeInOut(duration: 0.3), value: isInputBlocked)
    439                 }
    440                 .transition(.move(edge: .bottom))
    441             }
    442         }
    443         .frame(height: controlsPanelHeight, alignment: .top)
    444         .background {
    445             Color(.systemGroupedBackground)
    446                 .ignoresSafeArea(edges: .bottom)
    447         }
    448         .overlay(alignment: .top) {
    449             if controlsPanelHeight > 0 {
    450                 Rectangle()
    451                     .fill(Color(.opaqueSeparator))
    452                     .frame(height: 0.5)
    453             }
    454         }
    455         .animation(.easeOut(duration: 0.25), value: isSolved)
    456         .ignoresSafeArea(edges: .bottom)
    457     }
    458 
    459     private var controlsPanelHeight: CGFloat {
    460         isSolved || showsCustomKeyboard ? KeyboardView.standardHeight : 0
    461     }
    462 
    463     private var showsCustomKeyboard: Bool {
    464         !inputMonitor.isConnected
    465     }
    466 
    467     private func handleHardwareKeyboardEvent(_ event: HardwareKeyboardEvent) -> Bool {
    468         guard !isSolved, !isInputBlocked else { return false }
    469 
    470         // Undo/redo (⌘Z, ⇧⌘Z) live in the app menu (see PuzzleCommands) so they
    471         // appear in the hold-⌘ shortcut overlay. A ⌘-modified Z falls through
    472         // the letter case below (which rejects modifiers) and bubbles up to that
    473         // menu command.
    474         switch event.keyCode {
    475         case .keyboardA, .keyboardB, .keyboardC, .keyboardD, .keyboardE,
    476              .keyboardF, .keyboardG, .keyboardH, .keyboardI, .keyboardJ,
    477              .keyboardK, .keyboardL, .keyboardM, .keyboardN, .keyboardO,
    478              .keyboardP, .keyboardQ, .keyboardR, .keyboardS, .keyboardT,
    479              .keyboardU, .keyboardV, .keyboardW, .keyboardX, .keyboardY,
    480              .keyboardZ,
    481              .keyboard0, .keyboard1, .keyboard2, .keyboard3, .keyboard4,
    482              .keyboard5, .keyboard6, .keyboard7, .keyboard8, .keyboard9:
    483             guard !event.modifierFlags.contains(.command),
    484                   !event.modifierFlags.contains(.control),
    485                   !event.modifierFlags.contains(.alternate),
    486                   let character = hardwareKeyboardCharacter(from: event) else {
    487                 return false
    488             }
    489             if session.isRebusActive {
    490                 session.appendRebusLetter(character)
    491             } else {
    492                 session.enter(character)
    493             }
    494             return true
    495 
    496         case .keyboardDeleteOrBackspace, .keyboardDeleteForward:
    497             if session.isRebusActive {
    498                 session.deleteRebusLetter()
    499             } else {
    500                 session.deleteBackward()
    501             }
    502             return true
    503 
    504         case .keyboardLeftArrow:
    505             guard !session.isRebusActive else { return false }
    506             if event.modifierFlags.contains(.command) {
    507                 session.goToPreviousWord()
    508                 return true
    509             }
    510             moveWithHardwareArrow(direction: .across) {
    511                 session.goToPreviousLetter()
    512             }
    513             return true
    514 
    515         case .keyboardRightArrow:
    516             guard !session.isRebusActive else { return false }
    517             if event.modifierFlags.contains(.command) {
    518                 session.goToNextWord()
    519                 return true
    520             }
    521             moveWithHardwareArrow(direction: .across) {
    522                 session.goToNextLetter()
    523             }
    524             return true
    525 
    526         case .keyboardUpArrow:
    527             guard !session.isRebusActive else { return false }
    528             moveWithHardwareArrow(direction: .down) {
    529                 session.goToPreviousLetter()
    530             }
    531             return true
    532 
    533         case .keyboardDownArrow:
    534             guard !session.isRebusActive else { return false }
    535             moveWithHardwareArrow(direction: .down) {
    536                 session.goToNextLetter()
    537             }
    538             return true
    539 
    540         case .keyboardTab:
    541             guard !session.isRebusActive else { return false }
    542             if event.modifierFlags.contains(.shift) {
    543                 session.goToPreviousClue()
    544             } else {
    545                 session.goToNextClue()
    546             }
    547             return true
    548 
    549         case .keyboardSpacebar:
    550             guard !session.isRebusActive else { return false }
    551             session.toggleDirection()
    552             return true
    553 
    554         case .keyboardReturnOrEnter:
    555             if session.isRebusActive {
    556                 session.commitRebus()
    557             } else {
    558                 session.toggleDirection()
    559             }
    560             return true
    561 
    562         case .keyboardEscape:
    563             if session.isRebusActive {
    564                 session.commitRebus()
    565                 return true
    566             }
    567             return false
    568 
    569         default:
    570             return false
    571         }
    572     }
    573 
    574     private func hardwareKeyboardCharacter(from event: HardwareKeyboardEvent) -> String? {
    575         let scalars = event.charactersIgnoringModifiers.unicodeScalars
    576         guard scalars.count == 1, let scalar = scalars.first else { return nil }
    577 
    578         switch scalar.value {
    579         case 65...90, 97...122:  // A–Z, a–z
    580             return String(Character(scalar)).uppercased()
    581         case 48...57:            // 0–9
    582             return String(Character(scalar))
    583         default:
    584             return nil
    585         }
    586     }
    587 
    588     private func moveWithHardwareArrow(direction: Puzzle.Direction, move: () -> Void) {
    589         if session.direction != direction {
    590             let previousDirection = session.direction
    591             session.setDirection(direction)
    592             if session.direction != previousDirection {
    593                 return
    594             }
    595         }
    596 
    597         move()
    598     }
    599 
    600     private func leaveSharedGame() async {
    601         guard let shareController else { return }
    602         do {
    603             try await shareController.leaveShare(gameID: session.mutator.gameID)
    604             dismiss()
    605         } catch {
    606             leaveError = String(describing: error)
    607         }
    608     }
    609 }
    610 
    611 private struct RebusModal: View {
    612     @Binding var text: String
    613     var isFocused: FocusState<Bool>.Binding
    614     let onCommit: () -> Void
    615 
    616     var body: some View {
    617         // An editable field styled to read as the centred display card: the
    618         // system keyboard drives entry (so symbols, accents, and emoji are all
    619         // reachable) while the look and placement match the prior read-only modal.
    620         TextField("", text: $text)
    621             .focused(isFocused)
    622             .keyboardType(.asciiCapable)
    623             .textInputAutocapitalization(.characters)
    624             // .textInputAutocapitalization only sets the software keyboard's
    625             // shift state; hardware-keyboard input arrives as typed. Uppercase
    626             // the buffer directly so rebus fills are capitalised either way,
    627             // matching the custom-keyboard path in appendRebusLetter.
    628             .onChange(of: text) { _, newValue in
    629                 let upper = newValue.uppercased()
    630                 if upper != newValue { text = upper }
    631             }
    632             .autocorrectionDisabled()
    633             .submitLabel(.done)
    634             .onSubmit(onCommit)
    635             .multilineTextAlignment(.center)
    636             .font(.system(size: 32, weight: .semibold, design: .rounded))
    637             .foregroundStyle(.primary)
    638             .frame(maxWidth: .infinity, minHeight: 56)
    639             .padding(.horizontal, 16)
    640             .background(Color(.systemBackground))
    641             .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
    642             .padding(20)
    643             .frame(maxWidth: .infinity)
    644             .background(Color(.secondarySystemBackground))
    645             .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
    646     }
    647 }
    648 
    649 private struct ControlsView<Content: View>: View {
    650     let height: CGFloat
    651     @ViewBuilder var content: () -> Content
    652 
    653     var body: some View {
    654         VStack(spacing: 0) {
    655             content()
    656                 .frame(height: height)
    657             Color(.systemGroupedBackground)
    658         }
    659         .background(Color(.systemGroupedBackground))
    660         .ignoresSafeArea(edges: .bottom)
    661     }
    662 }