Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ComputerSolitaire.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 0.8.3;
MARKETING_VERSION = 0.8.4;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
Expand Down Expand Up @@ -574,7 +574,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 0.8.3;
MARKETING_VERSION = 0.8.4;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule;
Expand Down
104 changes: 56 additions & 48 deletions ComputerSolitaire/Views/Shared/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -262,67 +262,75 @@ struct ContentView: View {
view
.toolbar {
#if os(iOS)
ToolbarItem(placement: .bottomBar) {
Menu {
Section {
Button("New Game", systemImage: "plus") {
startNewGameFromUI()
}
Button("Redeal", systemImage: "arrow.clockwise") {
redealFromUI()
// The bottom bar is UIKit chrome layered above the SwiftUI
// overlay: left in place while the picker is up it stays
// undimmed and fully tappable, and it swallows scrim taps in
// the bottom strip. Removing its items is what keeps the
// picker modal — `.toolbar(.hidden, for: .bottomBar)` is inert
// here because no NavigationStack hosts the bar.
if !isShowingGamePicker {
ToolbarItem(placement: .bottomBar) {
Menu {
Section {
Button("New Game", systemImage: "plus") {
startNewGameFromUI()
}
Button("Redeal", systemImage: "arrow.clockwise") {
redealFromUI()
}
.disabled(!viewModel.canRedeal)
}
.disabled(!viewModel.canRedeal)
}
Section {
Button("Statistics", systemImage: "chart.bar") {
isShowingStats = true
Section {
Button("Statistics", systemImage: "chart.bar") {
isShowingStats = true
}
Button("Rules & Scoring", systemImage: "book") {
presentRulesAndScoring(initialSection: .rules)
}
}
Button("Rules & Scoring", systemImage: "book") {
presentRulesAndScoring(initialSection: .rules)
Section {
Button("Settings", systemImage: "gear") {
isShowingSettings = true
}
}
} label: {
Label("More", systemImage: "ellipsis")
}
Section {
Button("Settings", systemImage: "gear") {
isShowingSettings = true
}
ToolbarSpacer(.flexible, placement: .bottomBar)
if viewModel.isAutoFinishAvailable {
ToolbarItem(placement: .bottomBar) {
Button {
startAutoFinish()
} label: {
// The bottom bar renders Labels icon-only.
HStack(spacing: 5) {
Image(systemName: "bolt")
Text("Auto")
}
}
.accessibilityLabel("Auto Finish")
.disabled(isAutoFinishDisabled)
}
} label: {
Label("More", systemImage: "ellipsis")
ToolbarSpacer(.fixed, placement: .bottomBar)
}
}
ToolbarSpacer(.flexible, placement: .bottomBar)
if viewModel.isAutoFinishAvailable {
ToolbarItem(placement: .bottomBar) {
Button {
startAutoFinish()
} label: {
// The bottom bar renders Labels icon-only.
HStack(spacing: 5) {
Image(systemName: "bolt")
Text("Auto")
ToolbarItemGroup(placement: .bottomBar) {
if isHintButtonVisible {
Button {
triggerHint()
} label: {
Label("Hint", systemImage: "lightbulb")
}
.disabled(isHintDisabled)
}
.accessibilityLabel("Auto Finish")
.disabled(isAutoFinishDisabled)
}
ToolbarSpacer(.fixed, placement: .bottomBar)
}
ToolbarItemGroup(placement: .bottomBar) {
if isHintButtonVisible {
Button {
triggerHint()
stopAutoFinish()
beginUndoAnimationIfNeeded()
} label: {
Label("Hint", systemImage: "lightbulb")
Label("Undo", systemImage: "arrow.uturn.backward")
}
.disabled(isHintDisabled)
.disabled(isUndoDisabled)
}
Button {
stopAutoFinish()
beginUndoAnimationIfNeeded()
} label: {
Label("Undo", systemImage: "arrow.uturn.backward")
}
.disabled(isUndoDisabled)
}
#endif
#if os(macOS)
Expand Down
7 changes: 5 additions & 2 deletions ComputerSolitaire/Views/Shared/GameModePickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,13 +271,16 @@ struct GameModePickerOverlay: View {
var body: some View {
ZStack {
// The scrim is the picker's cancel button: clicking outside the
// panel dismisses, matching a system presentation.
// panel dismisses, matching a system presentation. Safe areas must
// be ignored on the button itself — expanding only the label paints
// edge to edge but leaves the hit area inset, deadening taps in the
// top and bottom strips on iOS.
Button(action: onDismiss) {
Color.black.opacity(0.35)
.ignoresSafeArea()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.ignoresSafeArea()
.accessibilityLabel("Dismiss game picker")

// Short windows (a ten-family gallery on a phone, macOS near its
Expand Down
32 changes: 32 additions & 0 deletions ComputerSolitaireUITests/GameModePickerUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,36 @@ final class GameModePickerUITests: XCTestCase {
XCTAssertTrue(yukonTitle.waitForExistence(timeout: 3), "Selecting the scrolled-to game should switch to it")
}
#endif
#if os(iOS)
/// The bottom strip is where a thumb naturally taps to dismiss, and it is
/// also where the bottom toolbar's chrome used to swallow scrim taps.
/// Regression coverage: the toolbar must leave while the picker is open,
/// and a tap in that strip must dismiss the overlay.
@MainActor
func testTapOutsideDismissesGamePicker() throws {
let app = XCUIApplication()
app.launch()

let titleButton = app.buttons.matching(
NSPredicate(format: "label CONTAINS 'Switch game mode'")
).firstMatch
XCTAssertTrue(titleButton.waitForExistence(timeout: 5), "Game title button should be on the board")
titleButton.tap()

let scrim = app.buttons["Dismiss game picker"]
XCTAssertTrue(scrim.waitForExistence(timeout: 3), "Picker overlay should open from the title button")
XCTAssertTrue(
app.buttons["Undo"].waitForNonExistence(timeout: 3),
"The bottom toolbar must leave while the picker is open — its chrome sits above the overlay and would swallow scrim taps"
)

app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.97)).tap()

XCTAssertTrue(scrim.waitForNonExistence(timeout: 3), "Tapping the bottom strip outside the panel should dismiss the picker overlay")
XCTAssertTrue(
app.buttons["Undo"].waitForExistence(timeout: 3),
"The bottom toolbar should return once the picker closes"
)
}
#endif
}