commit 8793ab064ba240a07f857b77af9730365590d944
parent 5401155b14fba730c8b6216bab2190d0d951c5ab
Author: Michael Camilleri <[email protected]>
Date: Wed, 6 May 2026 13:23:01 +0900
Add initial iPad layout
This adds a basic layout the iPad. It is... (wait for it) oriented
around landscape use.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
6 files changed, 541 insertions(+), 83 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -677,7 +677,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = net.inqk.crossmate;
SDKROOT = iphoneos;
- TARGETED_DEVICE_FAMILY = 1;
+ TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
@@ -695,7 +695,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = net.inqk.crossmate;
SDKROOT = iphoneos;
- TARGETED_DEVICE_FAMILY = 1;
+ TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -144,6 +144,22 @@ final class PlayerSession {
moveClue(by: -1)
}
+ func goToNextWord() {
+ moveWord(by: +1)
+ }
+
+ func goToPreviousWord() {
+ moveWord(by: -1)
+ }
+
+ func goToNextLetter() {
+ advance()
+ }
+
+ func goToPreviousLetter() {
+ retreat()
+ }
+
private func moveClue(by offset: Int) {
// Walk every clue in order: all acrosses, then all downs. Stepping past
// the last across rolls into the first down (and vice versa going
@@ -165,6 +181,16 @@ final class PlayerSession {
moveToClueStart(number: newClue.number)
}
+ private func moveWord(by offset: Int) {
+ let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues
+ guard !clues.isEmpty else { return }
+ let currentNumber = currentClueNumber()
+ let currentIndex = clues.firstIndex { $0.number == currentNumber } ?? 0
+ let count = clues.count
+ let nextIndex = ((currentIndex + offset) % count + count) % count
+ moveToClueStart(number: clues[nextIndex].number)
+ }
+
// MARK: - Check / Reveal / Clear
//
// These translate the player's cursor into a set of cells (or the whole
diff --git a/Crossmate/Views/ClueList.swift b/Crossmate/Views/ClueList.swift
@@ -2,46 +2,150 @@ import SwiftUI
struct ClueList: View {
@Bindable var session: PlayerSession
+ var presentation: Presentation = .sheet
@Environment(PlayerPreferences.self) private var preferences
@Environment(\.dismiss) private var dismiss
+ enum Presentation {
+ case sheet
+ case sidebar
+ }
+
var body: some View {
+ switch presentation {
+ case .sheet:
+ NavigationStack {
+ clueList
+ .navigationTitle("Clues")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button("Done") { dismiss() }
+ }
+ }
+ }
+ case .sidebar:
+ clueList
+ }
+ }
+
+ @ViewBuilder
+ private var clueList: some View {
let current = session.currentClue()
let currentDirection = session.direction
let currentID = current.map { rowID(direction: currentDirection, number: $0.number) }
- NavigationStack {
- ScrollViewReader { proxy in
- List {
- Section("Across") {
- ForEach(session.puzzle.acrossClues) { clue in
- row(for: clue, direction: .across, current: current, currentDirection: currentDirection)
- .id(rowID(direction: .across, number: clue.number))
- }
- }
- Section("Down") {
- ForEach(session.puzzle.downClues) { clue in
- row(for: clue, direction: .down, current: current, currentDirection: currentDirection)
- .id(rowID(direction: .down, number: clue.number))
- }
+ if presentation == .sheet {
+ list(
+ current: current,
+ currentDirection: currentDirection,
+ currentID: currentID
+ )
+ .listStyle(.insetGrouped)
+ } else {
+ sidebarList(
+ current: current,
+ currentDirection: currentDirection,
+ currentID: currentID
+ )
+ }
+ }
+
+ private func list(
+ current: Puzzle.Clue?,
+ currentDirection: Puzzle.Direction,
+ currentID: String?
+ ) -> some View {
+ ScrollViewReader { proxy in
+ List {
+ Section("Across") {
+ ForEach(session.puzzle.acrossClues) { clue in
+ row(for: clue, direction: .across, current: current, currentDirection: currentDirection)
+ .id(rowID(direction: .across, number: clue.number))
}
}
- .listStyle(.insetGrouped)
- .onAppear {
- guard let currentID else { return }
- proxy.scrollTo(currentID, anchor: .center)
+ Section("Down") {
+ ForEach(session.puzzle.downClues) { clue in
+ row(for: clue, direction: .down, current: current, currentDirection: currentDirection)
+ .id(rowID(direction: .down, number: clue.number))
+ }
}
}
- .navigationTitle("Clues")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- Button("Done") { dismiss() }
+ .onAppear {
+ guard let currentID else { return }
+ proxy.scrollTo(currentID, anchor: .center)
+ }
+ }
+ }
+
+ private func sidebarList(
+ current: Puzzle.Clue?,
+ currentDirection: Puzzle.Direction,
+ currentID: String?
+ ) -> some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ sidebarSectionHeader("Across")
+ ForEach(session.puzzle.acrossClues) { clue in
+ sidebarRow(for: clue, direction: .across, current: current, currentDirection: currentDirection)
+ .id(rowID(direction: .across, number: clue.number))
+ }
+
+ sidebarSectionHeader("Down")
+ .padding(.top, 12)
+ ForEach(session.puzzle.downClues) { clue in
+ sidebarRow(for: clue, direction: .down, current: current, currentDirection: currentDirection)
+ .id(rowID(direction: .down, number: clue.number))
+ }
}
+ .padding(.vertical, 10)
+ }
+ .onAppear {
+ guard let currentID else { return }
+ proxy.scrollTo(currentID, anchor: .center)
}
}
}
+ private func sidebarSectionHeader(_ title: String) -> some View {
+ Text(title)
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 18)
+ .padding(.vertical, 6)
+ }
+
+ private func sidebarRow(
+ for clue: Puzzle.Clue,
+ direction: Puzzle.Direction,
+ current: Puzzle.Clue?,
+ currentDirection: Puzzle.Direction
+ ) -> some View {
+ let isCurrent = current?.number == clue.number && currentDirection == direction
+ return Button {
+ session.selectClue(direction: direction, number: clue.number)
+ } label: {
+ HStack(alignment: .firstTextBaseline, spacing: 10) {
+ Text("\(clue.number)")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .frame(minWidth: 28, alignment: .trailing)
+ Text(clue.text)
+ .font(.body)
+ .foregroundStyle(.primary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(.horizontal, 18)
+ .padding(.vertical, 8)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .background(isCurrent ? preferences.color.highlightFill : Color.clear)
+ }
+
private func rowID(direction: Puzzle.Direction, number: Int) -> String {
"\(direction == .across ? "A" : "D")-\(number)"
}
@@ -56,7 +160,9 @@ struct ClueList: View {
let isCurrent = current?.number == clue.number && currentDirection == direction
Button {
session.selectClue(direction: direction, number: clue.number)
- dismiss()
+ if presentation == .sheet {
+ dismiss()
+ }
} label: {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("\(clue.number)")
diff --git a/Crossmate/Views/KeyboardView.swift b/Crossmate/Views/KeyboardView.swift
@@ -5,6 +5,7 @@ import SwiftUI
/// stay glued to the bottom of the screen alongside the grid.
struct KeyboardView: View {
@Bindable var session: PlayerSession
+ var showsNavigationKeys = false
@State private var showingOverflow = false
private let topRow = Array("QWERTYUIOP").map(String.init)
@@ -13,64 +14,100 @@ struct KeyboardView: View {
private let spacing: CGFloat = 6
private let keyHeight: CGFloat = 46
+ private let metaKeyWidthMultiplier: CGFloat = 1.5
static let standardHeight: CGFloat = 170
var body: some View {
VStack(spacing: spacing) {
- KeyboardRow(referenceColumns: 10, spacing: spacing, keyHeight: keyHeight) {
+ KeyboardRow(
+ referenceColumns: showsNavigationKeys ? 12 : 10,
+ spacing: spacing,
+ keyHeight: keyHeight
+ ) {
+ if showsNavigationKeys {
+ actionKey(systemImage: "chevron.left", accessibilityLabel: "Previous Word") {
+ session.goToPreviousWord()
+ }
+ .fillsExtraKeyboardSpace()
+ }
ForEach(topRow, id: \.self) { letter in
letterKey(letter)
}
+ if showsNavigationKeys {
+ actionKey(systemImage: "chevron.right", accessibilityLabel: "Next Word") {
+ session.goToNextWord()
+ }
+ .fillsExtraKeyboardSpace()
+ }
}
- KeyboardRow(referenceColumns: 10, spacing: spacing, keyHeight: keyHeight) {
+ KeyboardRow(
+ referenceColumns: showsNavigationKeys ? 12 : 10,
+ spacing: spacing,
+ keyHeight: keyHeight
+ ) {
+ if showsNavigationKeys {
+ actionKey(systemImage: "arrowtriangle.left.fill", accessibilityLabel: "Previous Letter") {
+ session.goToPreviousLetter()
+ }
+ .fillsExtraKeyboardSpace()
+ }
ForEach(middleRow, id: \.self) { letter in
letterKey(letter)
}
+ if showsNavigationKeys {
+ actionKey(systemImage: "arrowtriangle.right.fill", accessibilityLabel: "Next Letter") {
+ session.goToNextLetter()
+ }
+ .fillsExtraKeyboardSpace()
+ }
}
- KeyboardRow(referenceColumns: 10, spacing: spacing, keyHeight: keyHeight) {
- if session.isRebusActive {
- actionKey(text: "Done", background: .blue, foreground: .white) {
- session.commitRebus()
+ KeyboardRow(
+ referenceColumns: showsNavigationKeys ? 12 : 10,
+ spacing: spacing,
+ keyHeight: keyHeight
+ ) {
+ if showsNavigationKeys {
+ rebusKey
+ .disablesKeyboardMetaAnimations()
+ .fillsExtraKeyboardSpace()
+
+ actionKey(
+ systemImage: "pencil",
+ accessibilityLabel: session.isPencilMode ? "Turn Off Draft" : "Turn On Draft",
+ background: session.isPencilMode ? .blue : Color(.systemFill),
+ foreground: session.isPencilMode ? .white : .primary
+ ) {
+ session.togglePencil()
}
- .keyWidthMultiplier(1.5)
+ .disablesKeyboardMetaAnimations()
+ .fillsExtraKeyboardSpace()
} else {
- actionKey(systemImage: "ellipsis") {
- showingOverflow = true
- }
- .keyWidthMultiplier(1.5)
- .popover(isPresented: $showingOverflow) {
- VStack(alignment: .leading, spacing: 0) {
- Button {
- showingOverflow = false
- session.togglePencil()
- } label: {
- Text("Toggle Draft")
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 16)
- .padding(.vertical, 12)
- }
- .buttonStyle(.plain)
-
- Button {
- showingOverflow = false
- session.startRebus()
- } label: {
- Text("Enter Rebus")
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 16)
- .padding(.vertical, 12)
- }
- .buttonStyle(.plain)
+ if session.isRebusActive {
+ actionKey(text: "Done", background: .blue, foreground: .white) {
+ session.commitRebus()
}
- .frame(minWidth: 160)
- .presentationCompactAdaptation(.popover)
- .presentationBackground(.thinMaterial)
+ .keyWidthMultiplier(metaKeyWidthMultiplier)
+ } else {
+ overflowKey
}
}
+
ForEach(bottomLetters, id: \.self) { letter in
letterKey(letter)
}
+
+ if showsNavigationKeys {
+ actionKey(
+ systemImage: "arrow.2.squarepath",
+ accessibilityLabel: "Switch Direction"
+ ) {
+ session.toggleDirection()
+ }
+ .disablesKeyboardMetaAnimations()
+ .fillsExtraKeyboardSpace()
+ }
+
actionKey(systemImage: "delete.left") {
if session.isRebusActive {
session.deleteRebusLetter()
@@ -78,7 +115,9 @@ struct KeyboardView: View {
session.deleteBackward()
}
}
- .keyWidthMultiplier(1.5)
+ .disablesKeyboardMetaAnimations()
+ .keyWidthMultiplier(showsNavigationKeys ? 1 : metaKeyWidthMultiplier)
+ .fillsExtraKeyboardSpace(showsNavigationKeys)
}
}
.padding(.horizontal, 4)
@@ -113,19 +152,72 @@ struct KeyboardView: View {
.buttonStyle(.plain)
}
+ private var rebusKey: some View {
+ Group {
+ if session.isRebusActive {
+ actionKey(text: "Done", background: .blue, foreground: .white) {
+ session.commitRebus()
+ }
+ } else {
+ actionKey(text: "Rebus") {
+ session.startRebus()
+ }
+ }
+ }
+ }
+
+ private var overflowKey: some View {
+ actionKey(systemImage: "ellipsis") {
+ showingOverflow = true
+ }
+ .keyWidthMultiplier(metaKeyWidthMultiplier)
+ .popover(isPresented: $showingOverflow) {
+ VStack(alignment: .leading, spacing: 0) {
+ Button {
+ showingOverflow = false
+ session.togglePencil()
+ } label: {
+ Text("Toggle Draft")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ }
+ .buttonStyle(.plain)
+
+ Button {
+ showingOverflow = false
+ session.startRebus()
+ } label: {
+ Text("Enter Rebus")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ }
+ .buttonStyle(.plain)
+ }
+ .frame(minWidth: 160)
+ .presentationCompactAdaptation(.popover)
+ .presentationBackground(.thinMaterial)
+ }
+ }
+
private func actionKey(
systemImage: String,
+ accessibilityLabel: String? = nil,
+ background: Color = Color(.systemFill),
+ foreground: Color = .primary,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.system(size: 18, weight: .medium))
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(Color(.systemFill))
- .foregroundStyle(.primary)
+ .background(background)
+ .foregroundStyle(foreground)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
}
.buttonStyle(.plain)
+ .accessibilityLabel(accessibilityLabel ?? systemImage)
}
private func actionKey(
@@ -152,7 +244,8 @@ struct KeyboardView: View {
/// Lays out a row of keys at a fixed key height. Every key is sized as a
/// fraction of `referenceColumns`, so a row containing fewer keys ends up
/// narrower than the reference row and is centered. Action keys can opt into
-/// a wider width via `keyWidthMultiplier`.
+/// a wider width via `keyWidthMultiplier`, and selected keys can split any
+/// leftover row width via `fillsExtraKeyboardSpace`.
private struct KeyboardRow: Layout {
let referenceColumns: Int
let spacing: CGFloat
@@ -176,17 +269,12 @@ private struct KeyboardRow: Layout {
let columns = CGFloat(referenceColumns)
let baseKeyWidth = (containerWidth - spacing * (columns - 1)) / columns
- var totalWidth: CGFloat = 0
- for (index, subview) in subviews.enumerated() {
- let multiplier = subview[KeyWidthMultiplier.self]
- totalWidth += baseKeyWidth * multiplier
- if index > 0 { totalWidth += spacing }
- }
+ let widths = measuredWidths(for: subviews, baseKeyWidth: baseKeyWidth, containerWidth: containerWidth)
+ let totalWidth = widths.reduce(0, +) + spacing * CGFloat(max(0, subviews.count - 1))
var x = bounds.minX + (containerWidth - totalWidth) / 2
- for subview in subviews {
- let multiplier = subview[KeyWidthMultiplier.self]
- let width = baseKeyWidth * multiplier
+ for (index, subview) in subviews.enumerated() {
+ let width = widths[index]
subview.place(
at: CGPoint(x: x, y: bounds.minY),
anchor: .topLeading,
@@ -195,14 +283,49 @@ private struct KeyboardRow: Layout {
x += width + spacing
}
}
+
+ private func measuredWidths(
+ for subviews: Subviews,
+ baseKeyWidth: CGFloat,
+ containerWidth: CGFloat
+ ) -> [CGFloat] {
+ var widths = subviews.map { baseKeyWidth * $0[KeyWidthMultiplier.self] }
+ let fixedSpacing = spacing * CGFloat(max(0, subviews.count - 1))
+ let totalWidth = widths.reduce(0, +) + fixedSpacing
+ let flexibleIndexes = subviews.indices.filter { subviews[$0][FillsExtraKeyboardSpace.self] }
+ guard totalWidth < containerWidth, !flexibleIndexes.isEmpty else {
+ return widths
+ }
+
+ let extraWidth = (containerWidth - totalWidth) / CGFloat(flexibleIndexes.count)
+ for index in flexibleIndexes {
+ widths[index] += extraWidth
+ }
+ return widths
+ }
}
private struct KeyWidthMultiplier: LayoutValueKey {
static let defaultValue: CGFloat = 1.0
}
+private struct FillsExtraKeyboardSpace: LayoutValueKey {
+ static let defaultValue = false
+}
+
private extension View {
func keyWidthMultiplier(_ multiplier: CGFloat) -> some View {
layoutValue(key: KeyWidthMultiplier.self, value: multiplier)
}
+
+ func fillsExtraKeyboardSpace(_ fills: Bool = true) -> some View {
+ layoutValue(key: FillsExtraKeyboardSpace.self, value: fills)
+ }
+
+ func disablesKeyboardMetaAnimations() -> some View {
+ transaction { transaction in
+ transaction.animation = nil
+ transaction.disablesAnimations = true
+ }
+ }
}
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -20,6 +20,7 @@ struct PuzzleView: View {
@State private var isRevokedBannerDismissed = false
@State private var isShowingShareSheet = false
@State private var hasSolved = false
+ @State private var isLandscapePad = false
private func swatchImage(for color: PlayerColor) -> Image {
let tint = UIColor(color.tint)
@@ -47,9 +48,12 @@ struct PuzzleView: View {
private var isSolved: Bool { hasSolved }
var body: some View {
- VStack(spacing: 0) {
- puzzleArea
- controlsArea
+ Group {
+ if isLandscapePad {
+ landscapePadLayout
+ } else {
+ phoneLayout
+ }
}
.overlay(alignment: .top) {
if session.mutator.isAccessRevoked && !isRevokedBannerDismissed {
@@ -95,6 +99,57 @@ struct PuzzleView: View {
performDelete: performDelete,
leaveSharedGame: leaveSharedGame
))
+ .onAppear(perform: updateLayoutTrait)
+ .onReceive(NotificationCenter.default.publisher(
+ for: UIDevice.orientationDidChangeNotification
+ )) { _ in
+ updateLayoutTrait()
+ }
+ }
+
+ private var phoneLayout: some View {
+ VStack(spacing: 0) {
+ puzzleArea
+ controlsArea(showClueBar: true)
+ }
+ }
+
+ private var landscapePadLayout: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 0) {
+ VStack(spacing: 0) {
+ PuzzleScoreboard(session: session, roster: roster)
+
+ Divider()
+
+ ClueList(session: session, presentation: .sidebar)
+ }
+ .frame(minWidth: 300, idealWidth: 360, maxWidth: 420)
+ .background(Color(.secondarySystemBackground))
+
+ Divider()
+
+ puzzleArea
+ .padding(.bottom, 12)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+ controlsArea(showClueBar: false)
+ }
+ }
+
+ private func updateLayoutTrait() {
+ isLandscapePad = UIDevice.current.userInterfaceIdiom == .pad
+ && currentInterfaceOrientation?.isLandscape == true
+ }
+
+ private var currentInterfaceOrientation: UIInterfaceOrientation? {
+ UIApplication.shared.connectedScenes
+ .compactMap { $0 as? UIWindowScene }
+ .first { $0.activationState == .foregroundActive }?
+ .effectiveGeometry
+ .interfaceOrientation
}
private func performResign() {
@@ -157,15 +212,17 @@ struct PuzzleView: View {
}
}
- private var controlsArea: some View {
+ private func controlsArea(showClueBar: Bool) -> some View {
VStack(spacing: 0) {
- ClueBarSlot(session: session)
+ if showClueBar {
+ ClueBarSlot(session: session)
+ }
ZStack {
if isSolved {
SuccessPanel(session: session, roster: roster)
.transition(.move(edge: .bottom))
} else {
- KeyboardView(session: session)
+ KeyboardView(session: session, showsNavigationKeys: isLandscapePad)
.transition(.move(edge: .bottom))
}
}
@@ -190,6 +247,152 @@ struct PuzzleView: View {
}
}
+private struct PuzzleScoreboard: View {
+ @Bindable var session: PlayerSession
+ let roster: PlayerRoster?
+ @Environment(PlayerPreferences.self) private var preferences
+
+ private struct Score: Identifiable {
+ let authorID: String?
+ let name: String
+ let color: PlayerColor?
+ let filledCount: Int
+
+ var id: String { authorID ?? "unattributed" }
+ }
+
+ private var fillableCellCount: Int {
+ session.puzzle.cells.reduce(0) { count, row in
+ count + row.filter { !$0.isBlock }.count
+ }
+ }
+
+ private var filledCellCount: Int {
+ var count = 0
+ for r in 0..<session.puzzle.height {
+ for c in 0..<session.puzzle.width {
+ guard !session.puzzle.cells[r][c].isBlock else { continue }
+ if !session.game.squares[r][c].entry.isEmpty {
+ count += 1
+ }
+ }
+ }
+ return count
+ }
+
+ private var remainingCount: Int {
+ max(0, fillableCellCount - filledCellCount)
+ }
+
+ private var remainingText: String {
+ switch remainingCount {
+ case 0:
+ return "No letters to go"
+ case 1:
+ return "1 letter to go"
+ default:
+ return "\(remainingCount) letters to go"
+ }
+ }
+
+ private var scores: [Score] {
+ var counts: [String?: Int] = [:]
+ for r in 0..<session.puzzle.height {
+ for c in 0..<session.puzzle.width {
+ guard !session.puzzle.cells[r][c].isBlock else { continue }
+ let square = session.game.squares[r][c]
+ guard !square.entry.isEmpty else { continue }
+ counts[square.letterAuthorID, default: 0] += 1
+ }
+ }
+
+ let entries = roster?.entries ?? []
+ let usesLocalFallback = entries.isEmpty
+ let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) })
+ let rosterAuthorIDs = Set(entries.map(\.authorID))
+
+ let rosterScores: [Score]
+ if usesLocalFallback {
+ rosterScores = [
+ Score(
+ authorID: nil,
+ name: preferences.name,
+ color: preferences.color,
+ filledCount: counts[nil] ?? 0
+ )
+ ]
+ } else {
+ rosterScores = entries.map { entry in
+ Score(
+ authorID: entry.authorID,
+ name: entry.name,
+ color: entry.color,
+ filledCount: counts[entry.authorID] ?? 0
+ )
+ }
+ }
+
+ let extraScores = counts.compactMap { authorID, count -> Score? in
+ if let authorID, rosterAuthorIDs.contains(authorID) {
+ return nil
+ }
+ if authorID == nil && usesLocalFallback {
+ return nil
+ }
+ if let authorID, let entry = entryByAuthorID[authorID] {
+ return Score(authorID: authorID, name: entry.name, color: entry.color, filledCount: count)
+ }
+ if authorID == nil {
+ return Score(authorID: nil, name: "Unattributed", color: nil, filledCount: count)
+ }
+ return Score(authorID: authorID, name: "Player", color: nil, filledCount: count)
+ }
+
+ return (rosterScores + extraScores)
+ .sorted {
+ if $0.filledCount != $1.filledCount { return $0.filledCount > $1.filledCount }
+ return $0.name < $1.name
+ }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Players")
+ .font(.headline)
+
+ VStack(alignment: .leading, spacing: 6) {
+ ForEach(scores) { score in
+ scoreRow(score)
+ }
+
+ Text(remainingText)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .padding(.top, 10)
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ }
+ .padding(.horizontal, 18)
+ .padding(.vertical, 14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private func scoreRow(_ score: Score) -> some View {
+ HStack(spacing: 8) {
+ Circle()
+ .fill(score.color?.tint ?? Color.secondary)
+ .frame(width: 8, height: 8)
+ Text(score.name)
+ .font(.subheadline)
+ .lineLimit(1)
+ Spacer(minLength: 8)
+ Text("\(score.filledCount)")
+ .font(.subheadline.monospacedDigit().weight(.semibold))
+ }
+ .accessibilityElement(children: .combine)
+ }
+}
+
private struct PuzzleToolbarModifier: ViewModifier {
let session: PlayerSession
let roster: PlayerRoster?
diff --git a/project.yml b/project.yml
@@ -66,7 +66,7 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: net.inqk.crossmate
INFOPLIST_FILE: Crossmate/Info.plist
CODE_SIGN_ENTITLEMENTS: Crossmate/Crossmate.entitlements
- TARGETED_DEVICE_FAMILY: "1"
+ TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_STYLE: Automatic
Crossmate Unit Tests: