← poker-tutor
#if !os(Android)
import Foundation
#endif
// On Android, Skip's SwiftUI brings Foundation with it, and importing Foundation here as well
// leaves two `CGFloat`s in scope (Foundation's own and SwiftUI's stand-in) — nearly four hundred
// "ambiguous" errors, all from this one line. SwiftUI re-exports Foundation on Apple's platforms
// too, so nothing is lost either way.
import SwiftUI
import PokerCore
import PokerCoach
import PokerSession
/// The sizes everything is drawn at, decided once from the space available.
///
/// This is what stops the phone version being a second copy of every screen: the layouts read
/// these numbers instead of hard-coding Mac-sized ones. Controls get *larger* in the small layout,
/// not smaller — a thumb needs more room than a pointer does.
public struct Metrics: Equatable, Sendable {
public let width: CGFloat
public let height: CGFloat
/// Carried rather than looked up, so every size below is a plain function of the two things
/// that decide it — the space available and the player's setting. That is what lets a test walk
/// nine seats at four text sizes without a screen or a global to put back afterwards.
public let textScale: TextScale
public init(width: CGFloat, height: CGFloat, textScale: TextScale) {
self.width = width
self.height = height
self.textScale = textScale
}
@MainActor public init(width: CGFloat, height: CGFloat) {
self.init(width: width, height: height, textScale: Theme.textScale)
}
/// A phone, or a window squeezed down to phone width.
public var compact: Bool { width < 700 }
var gutter: CGFloat { compact ? 16 : 26 }
/// A screen's title. This already carries the player's text-size setting, so scaling it a
/// second time at the call site draws titles twice as far up as everything around them.
public var titleSize: CGFloat { textScale.size(compact ? 22 : 26) }
/// The app's own name on the home screen — the one piece of type set larger than a title.
var bigTitleSize: CGFloat { textScale.size(compact ? 24 : 28) }
/// How much the seat furniture — the name, the badge and the stack — actually grows by.
///
/// Everything a person *reads* on the table screen carries the setting in full: the running
/// commentary, the pot, the blinds, the buttons, the coach's review. The seat boxes are the one
/// exception, because nine of them have to fit round the felt at once, and past a certain size
/// they simply do not — the words get bigger and the table becomes unreadable, which is a poor
/// trade for the person who asked for bigger words.
///
/// So the limit is worked out from the space available rather than fixed: a Mac window grows
/// the whole way, a phone stops where it has to. `FeltRing.holds` is the same arithmetic the
/// felt is drawn with, so this cannot drift away from what actually gets laid out.
public var seatScale: CGFloat {
var scale = textScale.multiplier
while scale > Self.smallestSeat {
if ringHolds(at: scale) { return scale }
scale -= 0.02
}
return Self.smallestSeat
}
/// Below this the names under the cards stop being readable at all, so a window too small to
/// seat nine goes on and lets them touch rather than shrinking into nothing. Nothing the app
/// ships at needs it — it is the floor that stops a dragged-down window doing something absurd.
private static let smallestSeat: CGFloat = 0.74
private func ringHolds(at scale: CGFloat) -> Bool {
FeltRing(size: feltArea,
seatWidth: (seatBase * scale).rounded(),
seatHeight: seatHeightBase(at: scale).rounded(),
compact: compact)
.holds(TableSession.seatsAtTable)
}
private var seatBase: CGFloat { compact ? 86 : 140 }
/// The space the ring of seats actually gets, once the header above, the player's own row and
/// the buttons below have taken theirs. What the view lays out in is the real thing measured
/// at draw time; this is the same area worked out in advance, so the size can be settled
/// before anything is drawn.
public var feltArea: CGSize {
CGSize(width: width - (compact ? 8 : 40), height: height - chromeHeight - heroRowHeight)
}
/// Header, the action bar under the felt, and the padding around it — the space the ring never
/// gets. Deliberately generous: a seat that only just fits is a seat touching the buttons.
private var chromeHeight: CGFloat { compact ? 210 : 190 }
/// An opponent's seat box: their two cards, then the name and the stack, then whatever they
/// just did. Measured off the backs, which is what an opponent's seat actually holds.
private func seatHeightBase(at scale: CGFloat) -> CGFloat {
opponentCard + 5 + (compact ? 34 : 40) * scale + 22 * scale
}
/// Wide enough for a name, a seat badge and a stack — all three of which carry the player's
/// text-size setting, so the box around them has to carry it too or the writing is clipped the
/// moment it grows. The ring the seats sit on is measured off this, so the felt reflows to suit.
public var seatWidth: CGFloat { (seatBase * seatScale).rounded() }
/// A size for words that sit inside a seat box, so they grow by exactly as much as the box did.
public func seatText(_ base: CGFloat) -> CGFloat { max((base * seatScale).rounded(), 7) }
/// The five cards in the middle.
///
/// Ken, 2026-08-22: "can we make the flop cards a little bigger?" They were 32 points tall on a
/// phone, which is the smallest thing on the felt apart from the opponents' face-down cards —
/// and the board is what everybody at the table reads and re-reads all hand.
///
/// 40 rather than more, and the ceiling is geometry rather than taste: the board sits in the
/// middle of the felt with nine seats on a ring around it, and a board much larger runs into
/// the two at the sides. Matching the player's own cards at 46 already overlaps them on the
/// smallest iPhone the app supports. `boardClearance` is that gap, and a test walks it across
/// every screen at every text size — so the next request for bigger cards has an answer rather
/// than needing this worked out again.
public var boardCard: CGFloat { compact ? 40 : 58 }
/// Spacing between the board cards, which the width below has to account for.
public static let boardGap: CGFloat = 7
/// How wide the five board cards are, laid out together.
public var boardWidth: CGFloat {
5 * (boardCard * 0.72) + 4 * Metrics.boardGap
}
/// The gap between the edge of the board and the nearest edge of the seat boxes at the sides.
///
/// Negative means they overlap. The seats furthest out horizontally sit at `radiusX` from the
/// middle, and half a seat box reaches back in towards the board from there.
public var boardClearance: CGFloat {
let ring = FeltRing(size: feltArea, seatWidth: seatWidth,
seatHeight: ringSeatHeight, compact: compact)
return ring.radiusX - seatWidth / 2 - boardWidth / 2
}
/// The board and the pot under it, as one block centred on the felt: the cards, a 12-point
/// gap and the pot capsule, drawn six points above the middle. Where its bottom edge lands
/// is what decides that the player's row underneath never touches it.
public var boardBlockHeight: CGFloat { boardCard + 12 + 26 }
public var boardBottom: CGFloat { feltArea.height / 2 - 6 + boardBlockHeight / 2 }
// MARK: The player's cards
/// A card's width as a share of its height. `CardView` draws every card at this shape, and
/// every row of cards below is measured with it.
public static let cardAspect: CGFloat = 0.72
/// The gap between two cards laid side by side.
public static let cardGap: CGFloat = 3
/// The player's own cards, in a row of their own under the felt.
///
/// Ken, 2026-09-10: "I like the way the cards are presented in the PLO app. Can we have holdem
/// match it?" PLO Tutor took the player off the ring of seats because four whole cards do not
/// fit a seat box on a phone; the same row suits two cards just as well, and the two apps now
/// look alike. The player's cards sit between the felt and the buttons, side by side with a
/// gap, with the player's name and stack beside them, and the ring holds the eight opponents.
/// The row costs the felt 58 points of height on a phone; the ring is still 303 points tall
/// there, and `boardClearance` — which is horizontal — does not move.
///
/// 46 tall on a phone, as before: two of them are 69 points across on a 375-point screen,
/// with 298 to spare for the name, the stack and the bet. Every number here is checked by a
/// test at every screen size and text setting.
public var heroCard: CGFloat { compact ? 46 : 50 }
/// Two cards, this being hold'em. The row below is measured off it rather than off a literal.
public static let heroCardCount = 2
public var heroCardCount: Int { Metrics.heroCardCount }
public var heroCardWidth: CGFloat { heroCard * Metrics.cardAspect }
/// How wide the player's cards are, side by side with `cardGap` between them.
public var heroHandWidth: CGFloat { cardRowWidth(count: heroCardCount, height: heroCard) }
/// Where each of the player's cards sits within the row, left to right. The test that says the
/// cards never overlap reads these rather than trusting the words above.
public var heroCardFrames: [CGRect] {
(0..<heroCardCount).map { index in
CGRect(x: CGFloat(index) * (heroCardWidth + Metrics.cardGap), y: 0,
width: heroCardWidth, height: heroCard)
}
}
/// How wide a row of `count` cards is at a given card height, laid side by side.
public func cardRowWidth(count: Int, height: CGFloat) -> CGFloat {
CGFloat(count) * height * Metrics.cardAspect + CGFloat(max(0, count - 1)) * Metrics.cardGap
}
/// The gap between the cards and the box beside them, and between the box and the bet.
public var heroRowGap: CGFloat { 10 }
/// The room kept beside the player's box for what they just did — "Raise 12000" wants about
/// 95 points at the largest setting, and unlike an opponent's badge this one has nowhere to
/// hang over.
public var heroActionWidth: CGFloat { max(60, seatText(22) * 4.4).rounded() }
/// The whole row: cards, box, bet. Has to fit the screen, and a test says it does.
public var heroRowWidth: CGFloat {
heroHandWidth + heroRowGap + seatWidth + heroRowGap + heroActionWidth
}
/// The row's height: the cards, with a little felt above and below them.
public var heroRowHeight: CGFloat { heroCard + (compact ? 12 : 14) }
/// The lowest point any opponent's seat box reaches on the felt, `count` handed. The player's
/// row begins at the bottom of the felt, so this has to stay above it.
public func lowestSeatBottom(of count: Int) -> CGFloat {
let ring = FeltRing(size: feltArea, seatWidth: seatWidth,
seatHeight: ringSeatHeight, compact: compact)
return ring.points(of: count).dropFirst().map { $0.y + ringSeatHeight / 2 }.max() ?? 0
}
// MARK: The button and the blinds
/// Ken, 2026-09-11: "lets make the dealer have a button in front of their seat. It is hard to
/// see who has the button at any given time. Maybe also have some indicator on the table as to
/// who is the small and big blinds." The badge by each name still says "9 BTN", but a badge is
/// read; a disc on the felt is seen. So the button seat gets a white disc marked D on the felt
/// in front of it, and the two blinds get smaller dark chips marked SB and BB, the way a real
/// table shows them, and all three move round with the button every hand.
///
/// Where they sit is worked out from the same ring the seats are on: from the seat's centre,
/// straight towards the middle of the felt, to just past the edge of the seat box, plus
/// `markerInset` of felt, plus half the disc. The player has no seat on the ring — their row
/// is under the felt — so their marker sits at the empty bottom point of the ring instead,
/// which on a 375-point phone is 46 points above the felt's edge and 34 points clear of the
/// two corner seats (their boxes reach in to 45 points from the middle; a 22-point disc reaches
/// out to 11). Putting it beside the player's box in the row was the other option, and it lost:
/// the row is where the cards are read, and a marker there is one more thing beside them.
/// Heads-up the button posts the small blind, so that seat shows D and SB side by side.
/// Every marker is checked by a test at every screen size and text setting: inside the felt,
/// at least four points off its seat box, and never over the board.
public var dealerDisc: CGFloat { compact ? 22 : 26 }
public var blindMarker: CGFloat { compact ? 16 : 19 }
public var markerInset: CGFloat { 6 }
/// The least felt a marker keeps from anybody else's box and from the board.
public var markerClearance: CGFloat { 4 }
public var markerGap: CGFloat { 3 }
/// The centre of the marker for the seat `seatsFromHero` round the ring (0 is the player),
/// `count` handed, on a felt of `size` (the area the view actually got, or `feltArea` when
/// working it out in advance).
///
/// First choice is straight in front of the seat, towards the middle of the felt. Where that
/// lands on the board — the seats beside the board on a narrow phone have only about 24
/// points of felt between their box and the cards, and a disc with its inset needs 34 — the
/// marker goes just under the seat's box instead (for a seat in the top half of the felt) or
/// just over it (bottom half), then the other of those, then beside it on its outer side (the
/// seat at the very top of a short Mac window has the board under it and the edge over it).
/// Whichever
/// place is taken has to be on the felt, off every seat's box and off the board; the tests
/// walk all of them at every screen and text size.
public func markerCentre(seatsFromHero: Int, of count: Int, marks: Int = 1, in size: CGSize? = nil) -> CGPoint {
let area = size ?? feltArea
let ring = FeltRing(size: area, seatWidth: seatWidth, seatHeight: ringSeatHeight, compact: compact)
let point = ring.point(seatsFromHero: seatsFromHero, of: count)
guard seatsFromHero != 0 else { return point } // nothing is drawn at the player's point
let half = dealerDisc / 2
// D and SB side by side, heads-up: the run is wider than one disc.
let halfWidth = (dealerDisc + CGFloat(max(0, marks - 1)) * (blindMarker + markerGap)) / 2
let boxes = (1..<max(count, 1)).map { seat -> CGRect in
let p = ring.point(seatsFromHero: seat, of: count)
return CGRect(x: p.x - seatWidth / 2, y: p.y - ringSeatHeight / 2, width: seatWidth, height: ringSeatHeight)
}
let ownBox = boxes[seatsFromHero - 1]
let board = boardBlock(in: area).insetBy(dx: -markerClearance, dy: -markerClearance)
func clear(_ centre: CGPoint) -> Bool {
let disc = CGRect(x: centre.x - halfWidth, y: centre.y - half, width: halfWidth * 2, height: dealerDisc)
guard disc.minX >= 0, disc.minY >= 0, disc.maxX <= area.width, disc.maxY <= area.height else { return false }
if disc.intersects(board) { return false }
return !boxes.contains { disc.intersects($0.insetBy(dx: -markerClearance + 0.5, dy: -markerClearance + 0.5)) }
}
// Straight towards the middle, clearing the seat's own edge by the inset measured across
// that edge rather than along the slant — a seat on the diagonal otherwise has the disc's
// corner touching its box.
let dx = area.width / 2 - point.x, dy = area.height / 2 - point.y
let length = max(1, (dx * dx + dy * dy).squareRoot())
let ux = dx / length, uy = dy / length
let toSide = abs(ux) < 0.0001 ? CGFloat.infinity : (seatWidth / 2) / abs(ux)
let toTop = abs(uy) < 0.0001 ? CGFloat.infinity : (ringSeatHeight / 2) / abs(uy)
let across = toSide < toTop ? abs(ux) : abs(uy)
let travel = min(toSide, toTop) + (markerInset + (toSide < toTop ? halfWidth : half)) / across
let ahead = CGPoint(x: point.x + ux * travel, y: point.y + uy * travel)
let under = CGPoint(x: point.x, y: ownBox.maxY + markerInset + half)
let over = CGPoint(x: point.x, y: ownBox.minY - markerInset - half)
// Beside the box, on its outer side, for the seat at the very top of a short felt whose
// box has the board right under it and the felt's edge right over it.
let outward: CGFloat = point.x >= area.width / 2 ? 1 : -1
let beside = CGPoint(x: outward > 0 ? ownBox.maxX + markerInset + halfWidth : ownBox.minX - markerInset - halfWidth,
y: point.y)
let topHalf = point.y < area.height / 2
let candidates = [ahead] + (topHalf ? [under, over, beside] : [over, under, beside])
return candidates.first(where: clear) ?? ahead
}
/// The box a run of markers occupies, centred on `markerCentre`: one disc, or D and SB side
/// by side heads-up.
public func markerFrame(seatsFromHero: Int, of count: Int, marks: Int = 1, in size: CGSize? = nil) -> CGRect {
let centre = markerCentre(seatsFromHero: seatsFromHero, of: count, marks: marks, in: size)
let width = dealerDisc + CGFloat(max(0, marks - 1)) * (blindMarker + markerGap)
return CGRect(x: centre.x - width / 2, y: centre.y - dealerDisc / 2, width: width, height: dealerDisc)
}
/// An opponent's seat box on the felt, for the tests that keep the markers off it.
public func seatBox(seatsFromHero: Int, of count: Int) -> CGRect {
let ring = FeltRing(size: feltArea, seatWidth: seatWidth, seatHeight: ringSeatHeight, compact: compact)
let point = ring.point(seatsFromHero: seatsFromHero, of: count)
return CGRect(x: point.x - seatWidth / 2, y: point.y - ringSeatHeight / 2,
width: seatWidth, height: ringSeatHeight)
}
/// The board and the pot as one block on the felt, for the same tests.
public var boardBlock: CGRect { boardBlock(in: feltArea) }
public func boardBlock(in area: CGSize) -> CGRect {
CGRect(x: area.width / 2 - boardWidth / 2, y: area.height / 2 - 6 - boardBlockHeight / 2,
width: boardWidth, height: boardBlockHeight)
}
/// An opponent's cards: backs while they play, faces if they show.
public var opponentCard: CGFloat { compact ? 19 : 34 }
var faceDownCard: CGFloat { opponentCard }
/// Cards an opponent turned over are held a little larger than the backs, since these are
/// meant to be read.
public var opponentRevealCard: CGFloat { opponentCard * 1.12 }
/// How tall a seat box on the ring is: cards, then name and stack, then whatever they just
/// did. The ring keeps this much room at the top and the bottom or a chip count goes off the
/// edge of the felt — which is exactly what happened on a phone. The name and stack grow with
/// the text-size setting, so the room kept for them has to grow by the same amount.
public var ringSeatHeight: CGFloat { seatHeightBase(at: seatScale).rounded() }
// MARK: The by-seat table on My game
/// The three number columns of the by-seat table, and the gap between columns.
///
/// Capped, so they grow with the player's setting up to Large and no further — past that they
/// eat the seat name whole. A number that has lost its last digit is worse than useless: it
/// still looks like a number.
/// Sized to what they actually hold — "23", "45%", "+12.4 bb" — rather than to a round number.
/// The old widths were generous by about a third, which is where the row ran out of screen.
public var statHands: CGFloat { textScale.fittedSize(44) }
public var statPlayed: CGFloat { textScale.fittedSize(50) }
public var statPerHundred: CGFloat { textScale.fittedSize(64) }
public var statGap: CGFloat { textScale.fittedSize(4) }
/// Everything in a by-seat row that cannot shrink.
public var statFixedColumns: CGFloat {
statHands + statPlayed + statPerHundred + statGap * 3
}
/// The width a panel's contents actually get: the screen, less the margins either side of it,
/// less the padding inside the panel.
public var panelContentWidth: CGFloat { width - gutter * 2 - 36 }
/// What is left for the seat's own name once the numbers have taken theirs.
///
/// Ken, 2026-08-23: "on the my game screen, the text scrolls too far over to the right and cant
/// be fully read." Every column had a width of its own, multiplied by the text-size setting,
/// and four of them together outgrew the screen the moment the type went up — which is exactly
/// what somebody who finds a table hard to read does first. The seat column takes what is left
/// now, and this is the arithmetic that says what is left is enough to read.
public var statNameWidth: CGFloat { panelContentWidth - statFixedColumns }
/// Apple's guidance is a 44pt minimum touch target; the buttons here clear it.
var controlHeight: CGFloat { compact ? 46 : 34 }
var controlWidth: CGFloat { compact ? 72 : 92 }
}
private struct MetricsKey: EnvironmentKey {
/// Only ever the value before the first layout pass hands the real one down, so the setting it
/// is built with does not matter — but it has to be spelled out, since reading the player's
/// choice here would tie a plain default to the main actor.
static let defaultValue = Metrics(width: 1240, height: 820, textScale: .normal)
}
extension EnvironmentValues {
var metrics: Metrics {
get { self[MetricsKey.self] }
set { self[MetricsKey.self] = newValue }
}
}
/// How many colours the deck is printed in.
///
/// Four by default. The traditional two are kept because plenty of people have read cards that way
/// for fifty years and a repainted deck reads as wrong to them — but the default is the one that
/// answers the complaint, rather than a setting somebody has to go and find.
public enum CardColours: String, CaseIterable, Sendable, Identifiable {
case four
case two
public var id: String { rawValue }
public var name: String {
switch self {
case .four: return "A colour per suit"
case .two: return "Traditional red and black"
}
}
public var detail: String {
switch self {
case .four: return "Clubs and spades stop looking alike at a glance."
case .two: return "The deck you grew up with. Clubs and spades are both black."
}
}
public static var storageKey: String { AppVariant.current.key("cardColours") }
// `Foundation.UserDefaults`, spelled out: on Android, Skip's build plugin shadows the bare
// name with a module-private alias, and a public signature cannot mention a private type.
/// Anything unrecognised falls back to four rather than refusing to draw a card.
public static func stored(in defaults: Foundation.UserDefaults) -> CardColours {
guard let raw = defaults.string(forKey: storageKey),
let value = CardColours(rawValue: raw) else { return .four }
return value
}
public static func store(_ value: CardColours, in defaults: Foundation.UserDefaults) {
defaults.set(value.rawValue, forKey: storageKey)
}
/// The same, in the player's own settings. Internal so the default can be the module's
/// `UserDefaults.standard` — which on Android is Skip's, backed by the device's settings store.
static func stored() -> CardColours { stored(in: UserDefaults.standard) }
static func store(_ value: CardColours) { store(value, in: UserDefaults.standard) }
}
/// Which set of colours the app is wearing.
///
/// The default scheme says good in green and bad in red, which is the one pairing eight percent of
/// men cannot tell apart. The alternative swaps that axis for blue against orange — distinguishable
/// under every common form of colour blindness, and still obvious to everyone else.
public enum Palette: String, CaseIterable, Sendable, Identifiable {
case classic
case colourBlind
public var id: String { rawValue }
public var name: String {
switch self {
case .classic: return "Classic"
case .colourBlind: return "Colour-blind friendly"
}
}
/// Good news. Green normally; a light blue when green would be indistinguishable from red.
var accent: Color {
self == .colourBlind ? Color(red: 0.353, green: 0.678, blue: 0.937)
: Color(red: 0.373, green: 0.788, blue: 0.545)
}
/// Worth your attention but not an error.
var gold: Color {
self == .colourBlind ? Color(red: 0.933, green: 0.831, blue: 0.427)
: Color(red: 0.855, green: 0.706, blue: 0.373)
}
/// A mistake, or money lost. Red normally; a strong orange otherwise — orange against blue
/// survives every common form of colour blindness.
var warn: Color {
self == .colourBlind ? Color(red: 0.902, green: 0.549, blue: 0.196)
: Color(red: 0.898, green: 0.478, blue: 0.376)
}
/// The player themselves, as distinct from everyone else at the table.
var hero: Color {
self == .colourBlind ? Color(red: 0.749, green: 0.729, blue: 0.980)
: Color(red: 0.396, green: 0.643, blue: 0.937)
}
/// What a suit is printed in.
///
/// Ken, 2026-08-22: "hard to see the difference between the black suit." Clubs and spades are
/// the same colour in a traditional deck and very nearly the same shape at the size a phone
/// draws them, so the only thing separating them is a small notch at the bottom of the pip.
/// Giving every suit its own colour is what online poker settled on for exactly this reason,
/// and it is the setting most players who read a lot of cards turn on first.
///
/// Spades stay black. That matters: the point is to move the THREE that were sharing two
/// colours, not to repaint the deck into something unrecognisable.
func suitColour(_ suit: Suit, fourColour: Bool) -> Color {
let c = suitComponents(suit, fourColour: fourColour)
return Color(red: c.red, green: c.green, blue: c.blue)
}
/// The same colours as plain numbers.
///
/// SwiftUI's `Color` cannot be taken apart or compared, which would leave the one thing that
/// actually matters here untestable: that no two suits come out looking alike. The numbers are
/// the source and the `Color` is built from them, so there is no second copy to drift.
public func suitComponents(_ suit: Suit, fourColour: Bool) -> Components {
guard fourColour else {
switch suit {
case .hearts, .diamonds: return Palette.traditionalRed
case .clubs, .spades: return Palette.traditionalBlack
}
}
switch suit {
case .spades: return Palette.traditionalBlack
case .hearts: return self == .colourBlind
// Vermillion. Reads as red to anyone who can see red and as a dark warm tone to
// anyone who cannot, which is the point.
? Components(red: 0.835, green: 0.369, blue: 0.000)
: Palette.traditionalRed
case .diamonds: return self == .colourBlind
? Components(red: 0.000, green: 0.447, blue: 0.698)
// Orange, not blue. Ken, 2026-08-22: "Diamond color looked too much like a club."
// Blue and green are both dark and both cool, and they were being read on a green
// felt, which is the worst possible backdrop for telling them apart. Orange is the
// furthest thing on the wheel from the green that clubs keep.
//
// The cost is that hearts and diamonds are neighbours again, which is what the
// traditional deck already does — so the pair that shares a family is the pair players
// have always had share one, rather than the two the eye has no practice separating.
: Components(red: 0.914, green: 0.529, blue: 0.043)
case .clubs: return self == .colourBlind
// Reddish purple, not green. Green is the one colour a red-green blind reader cannot
// separate from hearts, and clubs against hearts is the pair this setting exists to
// fix. Darker than the usual reddish purple so it still reads on a cream card.
//
// These four are taken from the Okabe-Ito set, which was chosen for exactly this
// problem — four marks that stay apart under every common form of colour blindness —
// rather than picked by eye and hoped over.
? Components(red: 0.686, green: 0.278, blue: 0.545)
: Components(red: 0.075, green: 0.475, blue: 0.271)
}
}
public struct Components: Equatable, Sendable {
public let red: Double, green: Double, blue: Double
public init(red: Double, green: Double, blue: Double) {
self.red = red; self.green = green; self.blue = blue
}
}
static let traditionalRed = Components(red: 0.827, green: 0.239, blue: 0.271)
static let traditionalBlack = Components(red: 0.106, green: 0.129, blue: 0.157)
/// The four colours that carry meaning, so a scheme can be shown without being switched on.
/// `Theme.palette` is written by exactly one thing — `Theme.use` — and a row of swatches is
/// not it: repainting the whole app mid-draw to sample four colours worked only by accident.
func colours() -> [Color] { [accent, gold, warn, hero] }
public var detail: String {
switch self {
case .classic: return "Green for good, red for trouble."
case .colourBlind: return "Blue for good, orange for trouble — no red-green pairs anywhere."
}
}
}
/// How large the app draws its type.
///
/// The coach's write-ups are the reason the app exists and some of them run to a paragraph, so
/// these steps move the reading sizes properly rather than by a point. Words packed into something
/// a fixed size — a seat box, a chip, a button under the table — stop growing at Large instead:
/// past that the felt stops fitting on the screen. That cap is `fittedSize`, and it is the only
/// place the two behaviours differ.
///
/// Deliberately a value type with the multiplying and the remembering both on it, so a test can
/// check what a fresh launch will read back without going anywhere near a screen.
public enum TextScale: String, CaseIterable, Sendable, Identifiable {
case small
case normal
case large
case extraLarge
public var id: String { rawValue }
public var name: String {
switch self {
case .small: return "Small"
case .normal: return "Normal"
case .large: return "Large"
case .extraLarge: return "Extra large"
}
}
public var detail: String {
switch self {
case .small: return "More on the screen at once."
case .normal: return "The size everything was drawn at to begin with."
case .large: return "Easier going, especially on the coach's write-ups."
case .extraLarge: return "As big as it gets. The felt keeps its shape; the writing grows."
}
}
/// The coach's own writing — the reviews, the verdicts, the briefs and the reports — is set a
/// step above the app's ordinary text, at every one of the four settings. It is read rather
/// than glanced at, and it is the reason the app exists.
///
/// Applied to the BASE size, before the player's setting multiplies it, so the step up holds
/// at Small and at Extra large rather than washing out at the ends.
public static func coachBase(_ base: CGFloat) -> CGFloat { (base * 1.16).rounded() }
/// The walk-through, which gets the same step up as the coach's writing and for a plainer
/// reason: it is the one screen in the app somebody reads BEFORE they could possibly have
/// found the text-size setting. Everything else can be turned up by a player who finds it hard
/// going. The introduction has to be legible at whatever size it arrives at, first launch, on a
/// phone held at arm's length rather than a Mac at desk distance.
///
/// Ken, 2026-08-22, reading it on an iPhone for the first time: "Intro fonts are too small on
/// the phone. By a bit."
public static func introBase(_ base: CGFloat) -> CGFloat { coachBase(base) }
/// What every type size in the app is multiplied by.
public var multiplier: CGFloat {
switch self {
case .small: return 0.88
case .normal: return 1
case .large: return 1.18
case .extraLarge: return 1.38
}
}
/// A type size at this setting. Rounded to whole points: half a point of type sits visibly off
/// the baseline from the type beside it that landed on one.
public func size(_ base: CGFloat) -> CGFloat { (base * multiplier).rounded() }
/// The same, for words that have to stay inside something of a fixed size. It still grows —
/// just no further than Large does.
public func fittedSize(_ base: CGFloat) -> CGFloat {
(base * min(multiplier, TextScale.large.multiplier)).rounded()
}
// MARK: Remembering the choice
static var storageKey: String { AppVariant.current.key("textSize") }
/// What a fresh launch reads back. Anything unrecognised — an older version's value, or a hand
/// edit — falls back to Normal rather than refusing to draw.
public static func stored(in defaults: Foundation.UserDefaults) -> TextScale {
guard let raw = defaults.string(forKey: storageKey),
let scale = TextScale(rawValue: raw) else { return .normal }
return scale
}
public static func store(_ scale: TextScale, in defaults: Foundation.UserDefaults) {
defaults.set(scale.rawValue, forKey: storageKey)
}
static func stored() -> TextScale { stored(in: UserDefaults.standard) }
static func store(_ scale: TextScale) { store(scale, in: UserDefaults.standard) }
}
/// Colours, type and the small shared pieces. Everything visual lives in this target; no poker
/// logic does, which is what keeps the iPhone version a layout job rather than a rewrite.
@MainActor
enum Theme {
private static var paletteKey: String { AppVariant.current.key("palette") }
/// Read by every colour below. Static rather than passed down the view tree because the
/// alternative is threading a palette through several hundred call sites; the root view is
/// keyed on this value, so changing it redraws everything.
static var palette: Palette = {
guard let raw = UserDefaults.standard.string(forKey: paletteKey),
let stored = Palette(rawValue: raw) else { return .classic }
return stored
}()
static func use(_ palette: Palette) {
Theme.palette = palette
UserDefaults.standard.set(palette.rawValue, forKey: paletteKey)
}
/// Read by every type size below, for the same reason the palette is: the sizes are handed out
/// by free functions on `Text` that nothing can pass a value into. The root view is keyed on
/// this, so changing it throws the screen away and draws it again at the new size.
static var textScale: TextScale = TextScale.stored()
static func use(_ scale: TextScale) {
Theme.textScale = scale
TextScale.store(scale)
}
/// Whether every suit gets its own colour. Read by `CardView` and by nothing else.
static var cardColours: CardColours = CardColours.stored()
static func use(_ colours: CardColours) {
Theme.cardColours = colours
CardColours.store(colours)
}
static func suitColour(_ suit: Suit) -> Color {
palette.suitColour(suit, fourColour: cardColours == .four)
}
/// The size a piece of type is actually drawn at. `capped` is for words with a fixed-size box
/// around them — see `TextScale.fittedSize`.
static func coachBase(_ base: CGFloat) -> CGFloat { TextScale.coachBase(base) }
static func introBase(_ base: CGFloat) -> CGFloat { TextScale.introBase(base) }
static func size(_ base: CGFloat, capped: Bool = false) -> CGFloat {
capped ? textScale.fittedSize(base) : textScale.size(base)
}
private static var colourBlind: Bool { palette == .colourBlind }
static let background = Color(red: 0.055, green: 0.078, blue: 0.070)
static let panel = Color(red: 0.094, green: 0.125, blue: 0.114)
static let panelRaised = Color(red: 0.129, green: 0.169, blue: 0.153)
static let feltOuter = Color(red: 0.055, green: 0.207, blue: 0.129)
static let feltInner = Color(red: 0.086, green: 0.325, blue: 0.203)
static let rail = Color(red: 0.180, green: 0.114, blue: 0.078)
static let text = Color(red: 0.902, green: 0.925, blue: 0.914)
static let dim = Color(red: 0.545, green: 0.596, blue: 0.573)
/// The four colours that carry meaning all belong to the scheme itself, so one can be sampled
/// without the app being switched over to it. See `Palette.colours()`.
static var accent: Color { palette.accent }
static var gold: Color { palette.gold }
static var warn: Color { palette.warn }
static var hero: Color { palette.hero }
/// Chip colours follow the casino convention closely enough to be recognisable at a glance,
/// which is the entire reason for using chips rather than a number field. Every chip also
/// carries its value in writing, so the colour is a shortcut rather than the only signal.
static func chipFill(_ chip: Chip) -> Color {
switch chip {
case .twentyFive:
return colourBlind ? Color(red: 0.235, green: 0.522, blue: 0.776)
: Color(red: 0.180, green: 0.545, blue: 0.341)
case .fifty:
return colourBlind ? Color(red: 0.451, green: 0.451, blue: 0.478)
: Color(red: 0.204, green: 0.396, blue: 0.702)
case .hundred: return Color(red: 0.129, green: 0.145, blue: 0.169)
case .fiveHundred: return Color(red: 0.443, green: 0.286, blue: 0.639)
case .thousand: return Color(red: 0.867, green: 0.706, blue: 0.259)
case .fiveThousand:
return colourBlind ? Color(red: 0.831, green: 0.478, blue: 0.114)
: Color(red: 0.729, green: 0.216, blue: 0.235)
case .tenThousand: return Color(red: 0.514, green: 0.549, blue: 0.588)
}
}
static func chipText(_ chip: Chip) -> Color {
switch chip {
case .thousand, .tenThousand: return Color(red: 0.09, green: 0.10, blue: 0.11)
default: return .white
}
}
static func severityColour(_ severity: Severity) -> Color {
switch severity {
case .fine, .slight: return dim
case .minor: return gold
case .moderate: return colourBlind ? Color(red: 0.945, green: 0.667, blue: 0.310)
: Color(red: 0.933, green: 0.612, blue: 0.353)
case .major: return warn
}
}
}
/// The roles a piece of type can play, and the size each is drawn at before the player's setting
/// multiplies it.
///
/// Ken, 2026-09-12, on My game: "The font on my game seem very small. Let's make them bigger and
/// make sure that all of the text is consistent in terms of how font size and use of the font size
/// selector." Before this every screen picked its own numbers — 9-point column headings, 10-point
/// footnotes, 11-point captions, 12-point body — and the setting multiplied whatever it was handed,
/// so a screen drawn small stayed small at every setting. Now a view says what a piece of text IS
/// and the size follows from that: the same footnote is the same size on every screen, and a test
/// (`TextSizeTests`, "every size of type has a role") fails on any view that reaches for a number
/// below its role's floor.
///
/// Sizes are for a phone held at arm's length; the Mac gets the same and has the room.
public enum TextRole: String, CaseIterable, Sendable {
/// Dim footnotes, column headings, the small print under a number. Never below 12.
case caption
/// Ordinary reading text: card copy, rows, notes. Never below 14.
case body
/// Body with a little weight, for a line that has to stand out from the body around it.
case bodyStrong
/// A panel or row heading.
case heading
/// A screen's title.
case title
/// A number read as a number: stacks, rates, counts, digits aligned.
case numeric
/// A number sitting inside a caption or a table column, where the room is what it is.
case numericSmall
/// Words inside something of a fixed size — a seat box, the tab bar, the felt's badges. Always
/// capped (see `TextScale.fittedSize`), so the felt keeps its shape.
case felt
/// The size at the Normal setting, in points.
public var base: CGFloat {
switch self {
case .caption: return 12
case .body, .bodyStrong, .numeric: return 14
case .heading: return 16
case .title: return 20
case .numericSmall: return 13
case .felt: return 11
}
}
public var weight: Font.Weight {
switch self {
case .caption, .body: return .regular
case .bodyStrong, .felt: return .medium
case .heading, .title, .numeric, .numericSmall: return .semibold
}
}
/// Whether the role stops growing at Large — only the felt does.
public var isCapped: Bool { self == .felt }
}
/// Every size of type in the app comes through one of these, which is what makes the text-size
/// setting a five-line change rather than a hundred of them — and what makes "bigger" one number.
/// A role's base is the default; a view may ask for MORE (a display number at 46, a title at 22)
/// and never less. Pass `capped: true` when the words sit in something that cannot grow with
/// them — a seat box, a chip, a button under the felt.
@MainActor
extension Text {
func role(_ role: TextRole, capped: Bool = false) -> Text {
font(.system(size: Theme.size(role.base, capped: capped || role.isCapped),
weight: role.weight, design: .rounded))
}
func caption(weight: Font.Weight = .regular, capped: Bool = false) -> Text {
font(.system(size: Theme.size(TextRole.caption.base, capped: capped), weight: weight, design: .rounded))
}
func title(_ size: CGFloat = TextRole.title.base, capped: Bool = false) -> Text {
font(.system(size: Theme.size(max(size, TextRole.title.base), capped: capped), weight: .semibold, design: .rounded))
}
func heading(_ size: CGFloat = TextRole.heading.base, capped: Bool = false) -> Text {
font(.system(size: Theme.size(max(size, TextRole.heading.base), capped: capped), weight: .semibold, design: .rounded))
}
func body(_ size: CGFloat = TextRole.body.base, capped: Bool = false) -> Text {
font(.system(size: Theme.size(max(size, TextRole.body.base), capped: capped), weight: .regular, design: .rounded))
}
/// Felt-only: words inside a fixed box. Anything else that wants small medium type is a caption.
func label(_ size: CGFloat = TextRole.felt.base, capped: Bool = true) -> Text {
font(.system(size: Theme.size(size, capped: capped), weight: .medium, design: .rounded))
}
func numeric(_ size: CGFloat = TextRole.numeric.base, weight: Font.Weight = .semibold, capped: Bool = false) -> Text {
font(.system(size: Theme.size(max(size, TextRole.numeric.base), capped: capped), weight: weight, design: .rounded)).digitsAligned()
}
func numericSmall(weight: Font.Weight = .semibold, capped: Bool = false) -> Text {
font(.system(size: Theme.size(TextRole.numericSmall.base, capped: capped), weight: weight, design: .rounded)).digitsAligned()
}
}
/// A playing card.
struct CardView: View {
let card: Card?
var size: CGFloat = 46
var faceDown = false
/// Handed a card when one is touched, where the caller has somewhere to show it bigger.
///
/// A closure rather than reaching for `AppState`: this view is drawn inside sheets, replays and
/// seat rings as well as on the felt, and most of those have nowhere to put an enlarged card.
/// The ones that do say so.
var onTap: ((Card) -> Void)? = nil
private var suitColour: Color {
guard let card else { return .black }
return Theme.suitColour(card.suit)
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: size * 0.13, style: .continuous)
.fill(faceDown || card == nil
? AnyShapeStyle(LinearGradient(colors: [Color(red: 0.204, green: 0.263, blue: 0.396),
Color(red: 0.129, green: 0.176, blue: 0.290)],
startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color(red: 0.976, green: 0.973, blue: 0.961)))
.overlay(
RoundedRectangle(cornerRadius: size * 0.13, style: .continuous)
.stroke(Color.black.opacity(0.35), lineWidth: 0.6)
)
.shadow(color: .black.opacity(0.35), radius: 2, y: 1)
if faceDown || card == nil {
RoundedRectangle(cornerRadius: size * 0.09, style: .continuous)
.stroke(Color.white.opacity(0.16), lineWidth: 1)
.padding(size * 0.13)
} else if let card {
// Deliberately not scaled with the text-size setting: the rank and the pip are
// fractions of the card they are printed on, and a bigger rank on the same card is
// a rank hanging over the edge of it.
VStack(spacing: -size * 0.06) {
Text(String(card.rank.letter))
.font(.system(size: size * 0.46, weight: .bold, design: .rounded))
Text(card.suit.symbol)
.font(.system(size: size * 0.38, weight: .semibold))
}
.foregroundStyle(suitColour)
}
}
.frame(width: size * 0.72, height: size)
.contentShape(Rectangle())
.onTapGesture {
// Face-down cards have nothing to show. Enlarging the back of a card would be a
// gesture that appears to do something and does not.
if let card, !faceDown, let onTap { onTap(card) }
}
}
}
/// One card, held up large enough to read without leaning in.
///
/// Ken, 2026-08-22: "lets make it so that if a user touches a card, it gets bigger so they can see
/// it clearly." Drawn over the felt rather than by scaling the card where it sits — a card in the
/// seat ring is inside a frame that would clip it, and a card growing in place shoves the board
/// along. This has the screen to itself and goes away on the next touch.
struct MagnifiedCard: View {
let card: Card
let dismiss: () -> Void
@Environment(\.metrics) var metrics
var body: some View {
ZStack {
Color.black.opacity(0.55)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture(perform: dismiss)
VStack(spacing: 14) {
CardView(card: card, size: metrics.compact ? 220 : 260)
Text(Copy.tapToPutItBack)
.body().foregroundStyle(Theme.text.opacity(0.75))
}
.contentShape(Rectangle())
.onTapGesture(perform: dismiss)
}
.transition(.opacity)
}
}
/// The chips a player has in front of them this round, with what they just did if it is worth
/// saying — "Raise 600" reads as a raise; "600" on its own does not.
struct ChipsView: View {
@Environment(\.metrics) var metrics
let amount: Int
var caption: String? = nil
var body: some View {
HStack(spacing: 4) {
ZStack {
Circle().fill(Theme.gold).frame(width: 11, height: 11)
Circle().stroke(Color.black.opacity(0.35), lineWidth: 1).frame(width: 11, height: 11)
}
// A step above the name and stack around it. This is the one thing on a seat that
// changes every few seconds and has to be read while it does — the rest can be looked
// up at leisure.
Text(caption ?? "\(amount)")
.font(.system(size: metrics.seatText(12), weight: .semibold, design: .rounded))
.digitsAligned()
.foregroundStyle(Theme.text.opacity(0.9))
}
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(Color.black.opacity(0.38)))
}
}
/// A titled block used on every screen that is not the table.
struct Panel<Content: View>: View {
let title: String
var subtitle: String? = nil
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(title).heading().foregroundStyle(Theme.text)
if let subtitle {
Text(subtitle).body().foregroundStyle(Theme.dim)
}
}
content
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(Theme.panel))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Color.white.opacity(0.05), lineWidth: 1))
}
}
/// A horizontal bar showing a rate against the level a competent player sits at.
struct RateBar: View {
let rate: Double
let acceptable: Double
var height: CGFloat = 7
/// Whether there is anything behind the rate. An unmeasured row draws the track and the mark
/// and no fill: a rate of nought off no chances would draw the short green bar that means "this
/// part of your game is working", which is a claim nobody has measured.
var measured = true
var body: some View {
GeometryReader { geo in
let width = geo.size.width
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.07))
if measured {
Capsule()
.fill(rate > acceptable ? Theme.warn : Theme.accent)
.frame(width: max(3, width * min(1, rate)))
}
Rectangle()
.fill(Theme.text.opacity(0.55))
.frame(width: 1.5, height: height + 4)
.offset(x: width * min(1, acceptable))
}
}
.frame(height: height)
}
}
struct PillButton: View {
let title: String
var subtitle: String? = nil
var tint: Color = Theme.accent
/// The second button of a pair — "Read the report" beside "Start", "Close" beside "See my
/// game". It used to be drawn as a dark pill with the *dark* label colour the bright pills use,
/// which is the look of a button that cannot be pressed; Ken reported it as greyed out. A quiet
/// button keeps the light text and gets an edge, so it reads as a button that simply shouts less.
var quiet = false
var wide = false
var enabled = true
let action: () -> Void
@Environment(\.metrics) var metrics
@State var hovering = false
var body: some View {
Button(action: action) {
VStack(spacing: 2) {
// Capped: these sit in a button of a fixed height, with the felt above them.
Text(title).heading(capped: true)
if let subtitle {
Text(subtitle).caption(capped: true)
.opacity(0.75)
.multilineTextAlignment(.center)
}
}
.foregroundStyle(quiet ? Theme.text : Theme.background)
.frame(maxWidth: wide ? .infinity : nil)
.frame(minWidth: metrics.controlWidth,
minHeight: subtitle == nil ? metrics.controlHeight : metrics.controlHeight + 12)
.padding(.horizontal, metrics.compact ? 12 : 16)
.background(
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(quiet ? Theme.panelRaised.opacity(hovering ? 1.0 : 0.9)
: tint.opacity(hovering ? 1.0 : 0.88))
)
.overlay(
RoundedRectangle(cornerRadius: 9, style: .continuous)
.stroke(Theme.dim.opacity(quiet ? 0.55 : 0), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
.opacity(enabled ? 1 : 0.35)
}
.buttonStyle(.plain)
.disabled(!enabled)
.hoverHighlight { hovering = $0 && enabled }
}
}
struct GhostButton: View {
let title: String
var tint: Color = Theme.text
var wide = false
/// Sizing chips carry two or three characters; giving them a full button's width is what
/// pushes the Raise button off the edge of a narrow window.
var narrow = false
let action: () -> Void
@Environment(\.metrics) var metrics
@State var hovering = false
var body: some View {
Button(action: action) {
Text(title).label(capped: true)
.foregroundStyle(tint)
.frame(maxWidth: wide ? .infinity : nil)
.frame(minWidth: narrow ? 58 : metrics.controlWidth, minHeight: metrics.controlHeight)
.padding(.horizontal, narrow ? 8 : (metrics.compact ? 12 : 16))
.background(
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(Color.white.opacity(hovering ? 0.13 : 0.07))
)
.contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
}
.buttonStyle(.plain)
.hoverHighlight { hovering = $0 }
}
}
/// Whose app this is, said where somebody will actually see it.
///
/// Ken, 2026-08-02: "I dont see any branding on the app." The first attempt put it in a panel at the
/// bottom of Settings, which is branding somebody has to go looking for — so it was not branding.
/// This sits under the name on the home screen and under the wordmark in the sidebar, and it opens
/// the site.
///
/// The olive is The (Digital) Ad Lab's own, taken from its stylesheet rather than approximated, and
/// it is the one place in the app that colour appears — a brand mark should not be repainted by the
/// colour-blind palette, because then it is no longer the mark.
struct AdLabMark: View {
var size: CGFloat = 11
var showRule = true
/// #8cb04a — the olive from thedigitaladlab.com, and the same one in the app icon.
static let olive = Color(red: 0.549, green: 0.690, blue: 0.290)
var body: some View {
Link(destination: URL(string: Copy.adLabURL)!) {
HStack(spacing: 7) {
if showRule {
RoundedRectangle(cornerRadius: 1, style: .continuous)
.fill(AdLabMark.olive)
.frame(width: Theme.size(size) * 1.5, height: 2)
}
Text(Copy.madeByTitle)
.font(.system(size: Theme.size(size), weight: .semibold, design: .serif))
.foregroundStyle(AdLabMark.olive)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Pointer-only niceties
extension View {
/// Hover feedback where there is a pointer to hover with. A phone has none, and Skip's Android
/// SwiftUI has no such modifier, so there it is a no-op rather than a build error.
@ViewBuilder func hoverHighlight(_ perform: @escaping (Bool) -> Void) -> some View {
#if os(Android)
self
#else
self.onHover(perform: perform)
#endif
}
/// A tooltip, on the platforms that draw them.
@ViewBuilder func tooltip(_ text: String) -> some View {
#if os(Android)
self
#else
self.help(text)
#endif
}
}
#if os(Android)
extension View {
/// Skip's SwiftUI has no `contentShape` yet. On Android every button is tappable across its
/// whole frame already, so this leaves the view as it is rather than failing the build.
func contentShape<S: Shape>(_ shape: S) -> some View { self }
}
#endif
// MARK: - Modifiers Skip's Android SwiftUI does not have yet
//
// Each of these is the real modifier on Apple's platforms and a no-op on Android, so a screen can
// ask for the nicety once and the build decides. When Skip grows the modifier, delete the shim.
extension View {
/// `menuStyle(.borderlessButton)`: a menu drawn as its label alone.
@ViewBuilder func plainMenuStyle() -> some View {
#if os(Android)
self
#else
self.menuStyle(.borderlessButton)
#endif
}
/// `controlSize(.mini)`: the smallest switch.
@ViewBuilder func miniControl() -> some View {
#if os(Android)
self
#else
self.controlSize(.mini)
#endif
}
/// `accessibilityElement(children: .ignore)`: read this view out as one thing.
@ViewBuilder func oneAccessibleElement() -> some View {
#if os(Android)
self
#else
self.accessibilityElement(children: .ignore)
#endif
}
}
extension Text {
/// `monospacedDigit()`: figures that line up in columns. Android's rounded face is close enough
/// until Skip offers the real thing.
func digitsAligned() -> Text {
#if os(Android)
self
#else
self.monospacedDigit()
#endif
}
}
// MARK: - Symbols
/// An Apple symbol by name, drawn wherever the app runs.
///
/// Apple's platforms have the whole SF Symbols set. Skip's Android SwiftUI has a few dozen of
/// them, so the rest are shapes drawn by hand and carried in `Resources/Module.xcassets` as
/// white templates that take the current colour — or, where a close cousin exists in Skip's
/// set, that cousin. Use this everywhere rather than `Image(systemName:)`, or an icon that looks
/// fine on the Mac will draw as a warning triangle on a phone.
struct Symbol: View {
let name: String
var size: CGFloat = 14
init(_ name: String, size: CGFloat = 14) {
self.name = name
self.size = size
}
var body: some View {
#if os(Android)
if let bundled = Symbol.bundled[name] {
Image(bundled, bundle: .module)
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size * 1.25, height: size * 1.25)
} else {
Image(systemName: Symbol.cousin[name] ?? name).font(.system(size: size))
}
#else
Image(systemName: name).font(.system(size: size))
#endif
}
/// Symbols Skip cannot draw, and the bundled shape that stands in for each.
static let bundled: [String: String] = [
"suit.spade": "tab-table", "suit.spade.fill": "tab-table",
"suit.club": "tab-club", "suit.club.fill": "tab-club",
"rectangle.stack.fill": "glyph-cards",
"target": "tab-practice",
"chart.bar.doc.horizontal": "tab-leaks",
"clock.arrow.circlepath": "tab-history",
"chart.line.uptrend.xyaxis": "tab-ladder",
"speaker.wave.2": "glyph-speaker", "speaker.slash": "glyph-speaker-off",
"hourglass": "glyph-hourglass",
"exclamationmark.circle": "glyph-alert",
"xmark.circle.fill": "glyph-close",
"line.3.horizontal.decrease.circle": "glyph-filter",
"largecircle.fill.circle": "glyph-radio-on", "circle": "glyph-radio-off",
"circle.grid.3x3.fill": "glyph-grid",
]
/// Symbols Skip cannot draw that have a near enough relative it can.
static let cousin: [String: String] = [
"checkmark.seal": "checkmark.circle",
"arrow.counterclockwise": "arrow.clockwise.circle",
"person.2.fill": "person.fill",
]
}
// MARK: - The free tier's blur
/// A row a free player can see is there but cannot read.
///
/// The text stays in the tree at its real size — so the row keeps its height and the list keeps its
/// shape, which is the whole point: somebody can see there are other habits in their game — and is
/// redacted and blurred so the name and the numbers cannot be made out. The accessibility label
/// replaces the content, so a screen reader does not read the name aloud either. Tapping goes to
/// the price.
struct HiddenUntilSubscribed: ViewModifier {
let hidden: Bool
let open: () -> Void
func body(content: Content) -> some View {
if hidden {
Button(action: open) {
content
.redacted(reason: .placeholder)
.blur(radius: 5)
.overlay(alignment: .trailing) {
Symbol("lock.fill", size: Theme.size(12, capped: true))
.foregroundStyle(Theme.dim)
.padding(.trailing, 4)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Copy.hiddenRowLabel)
.accessibilityAddTraits(.isButton)
} else {
content
}
}
}
extension View {
/// Blurs and redacts the view when `hidden`, sending a tap to `open` (the paywall).
func hiddenUntilSubscribed(_ hidden: Bool, open: @escaping () -> Void) -> some View {
modifier(HiddenUntilSubscribed(hidden: hidden, open: open))
}
}