Files
proton-stream/ios/ProtonStream/UI/Theme/Theme.swift
T
nvrl ed05a1e723 feat(ios): port the Android client to SwiftUI
The iOS client is now a screen-for-screen port of the Android app over the
same pstr-android UniFFI surface, with no Rust changes: Library, title page,
History, Shares (public links and Drive folders), Downloads and Settings,
with the same strings, undo snackbars and two-pane layout on wide screens.

- Theme from the engine's palette (Material role names, Inter, the same
  type ramp and corners), repainted live from the appearance settings.
- Account sign-in with two-factor code, mailbox password and the Proton
  CAPTCHA in a non-persistent WKWebView, the Drive folder browser, and
  watch-history sync every five minutes, after sign-in, on player close and
  when the app leaves the screen.
- Player over libmpv: resume, progress saved every 5 s, autoplay with the
  up-next card, skip and auto-skip from the chapter plan, track, speed,
  volume and episode pickers, landscape lock, mini transport, and
  lock-screen controls with interruption and route-change handling.
- Offline downloads with the Android record format, free-space floor,
  retries, transfer rate and the Wi-Fi-only policy; interrupted transfers
  resume at next launch.
- Config, data and download state are excluded from backup, as Android's
  allowBackup=false: a restore would bring shares back without their
  Keychain secrets and give two devices one sync identity.

Picture-in-picture, the share sheet and background downloads are not
possible the same way on iOS; docs/IOS.md lists why and what replaces them.

Adds XCTest ports of the Android unit tests, a `test` command to
scripts/build-ios.sh, the app icon and its render script, and an iOS
workflow on macos-15 that runs the tests and builds the unsigned .ipa.
2026-10-07 00:32:03 +02:00

195 lines
6.9 KiB
Swift

import Observation
import SwiftUI
/// The palette in force, shared across every view.
///
/// The choice is stored by Rust and resolved by Rust — the same flavours, the
/// same accent pairings and the same contrast rule the desktop client uses — so
/// what lives here is only the resolved answer and a way to replace it when the
/// viewer picks something else.
@MainActor
@Observable
final class AppearanceState {
static let shared = AppearanceState()
private(set) var palette: PaletteRecord?
/// Before the first read, and if it fails, the palette this app shipped
/// with — a theme file that cannot be read must never be a reason the
/// library does not open.
var scheme: Scheme { Scheme(palette ?? protonFallback) }
/// The palette read before the first frame, from
/// `NativeRuntime.initialPalette()`.
func seed(_ palette: PaletteRecord?) {
if let palette { self.palette = palette }
}
/// Read the stored choice through the engine. A no-op once `seed` has run.
func load() async {
if palette != nil { return }
if let read = try? await Task.detached(operation: { try NativeRuntime.blockingEngine().palette() }).value {
palette = read
}
}
/// Repaint immediately, before the write that stores the choice lands.
func apply(_ palette: PaletteRecord) {
self.palette = palette
}
}
/// One resolved palette as the roles the views draw with — Material's names,
/// so a view ported from the Android client asks for the same role.
///
/// Every role is answered from the palette, and the mapping is Android's
/// `schemeOf` line for line; `SchemeRolesTests` pins it.
struct Scheme: Equatable {
let light: Bool
let primary: Color
let onPrimary: Color
let primaryContainer: Color
let onPrimaryContainer: Color
let inversePrimary: Color
let secondary: Color
let onSecondary: Color
let secondaryContainer: Color
let onSecondaryContainer: Color
let tertiary: Color
let onTertiary: Color
let tertiaryContainer: Color
let onTertiaryContainer: Color
let background: Color
let onBackground: Color
let surface: Color
let onSurface: Color
let surfaceVariant: Color
let onSurfaceVariant: Color
let inverseSurface: Color
let inverseOnSurface: Color
let error: Color
let onError: Color
let errorContainer: Color
let onErrorContainer: Color
let outline: Color
let outlineVariant: Color
let scrim: Color
let surfaceBright: Color
let surfaceDim: Color
let surfaceContainerLowest: Color
let surfaceContainerLow: Color
let surfaceContainer: Color
let surfaceContainerHigh: Color
let surfaceContainerHighest: Color
init(_ palette: PaletteRecord) {
light = palette.light
primary = argb(palette.accent)
onPrimary = argb(palette.onAccent)
primaryContainer = argb(palette.accentDim)
onPrimaryContainer = argb(palette.text)
// Readable *on* `inverseSurface`, which is the ink colour: `accentDim`
// is the accent taken towards the page, so it inverts with the rest.
inversePrimary = argb(palette.accentDim)
secondary = argb(palette.accentAlt)
onSecondary = argb(palette.onAccent)
// The accent taken back towards the page, not the card's hover shade:
// it fills every tonal button.
secondaryContainer = argb(palette.accentDim)
onSecondaryContainer = argb(palette.text)
// The palette has two hues, not three. Tertiary takes the gradient
// partner rather than inventing a third that no flavour chose.
tertiary = argb(palette.accentAlt)
onTertiary = argb(palette.onAccent)
tertiaryContainer = argb(palette.accentDim)
onTertiaryContainer = argb(palette.text)
background = argb(palette.background)
onBackground = argb(palette.text)
surface = argb(palette.surface)
onSurface = argb(palette.text)
surfaceVariant = argb(palette.card)
onSurfaceVariant = argb(palette.muted)
// Genuinely inverted, both ways round: on a dark flavour this is a pale
// snackbar with dark ink, on Latte a dark one with pale ink.
inverseSurface = argb(palette.text)
inverseOnSurface = argb(palette.background)
error = argb(palette.danger)
onError = argb(palette.background)
errorContainer = argb(palette.dangerDim)
onErrorContainer = argb(palette.text)
// `outline` is a border meant to be seen, `outlineVariant` a divider
// meant to be barely there.
outline = argb(palette.muted)
outlineVariant = argb(palette.border)
scrim = .black
surfaceBright = argb(palette.elevated)
surfaceDim = argb(palette.background)
// The containment ladder, in the order the palette already runs.
surfaceContainerLowest = argb(palette.sunken)
surfaceContainerLow = argb(palette.surface)
surfaceContainer = argb(palette.card)
surfaceContainerHigh = argb(palette.cardHover)
surfaceContainerHighest = argb(palette.elevated)
}
var colorScheme: ColorScheme { light ? .light : .dark }
/// The accent, as a gradient when there is one to draw. With gradients
/// off, `Palette::resolve` sets `accent_alt` equal to `accent`, so this
/// collapses to a flat fill on its own.
var accentGradient: LinearGradient {
LinearGradient(colors: [primary, secondary], startPoint: .leading, endPoint: .trailing)
}
}
/// A packed `0xAARRGGBB` from the bridge, as SwiftUI wants it.
func argb(_ value: UInt32) -> Color {
Color(
.sRGB,
red: Double((value >> 16) & 0xFF) / 255,
green: Double((value >> 8) & 0xFF) / 255,
blue: Double(value & 0xFF) / 255,
opacity: Double((value >> 24) & 0xFF) / 255
)
}
/// The shipped default, spelled out for the case where Rust cannot be asked.
/// The values are `pstr_core::appearance::Palette::PROTON`.
let protonFallback = PaletteRecord(
background: 0xFF0E_0E12,
surface: 0xFF17_171D,
sunken: 0xFF0A_0A0D,
card: 0xFF1E_1E26,
cardHover: 0xFF2A_2A35,
border: 0xFF26_2630,
text: 0xFFEA_EAF0,
muted: 0xFF8E_8E9C,
accent: 0xFF7D_4DFF,
accentAlt: 0xFF3F_6FE0,
accentDim: 0xFF53_33AD,
onAccent: 0xFFEA_EAF0,
danger: 0xFFE0_5561,
elevated: 0xFF33_3340,
dangerDim: 0xFF7F_2F37,
light: false
)
private struct SchemeKey: EnvironmentKey {
static let defaultValue = Scheme(protonFallback)
}
extension EnvironmentValues {
var scheme: Scheme {
get { self[SchemeKey.self] }
set { self[SchemeKey.self] = newValue }
}
}
/// One corner radius, which is the desktop client's corner radius: 8 for
/// everything, 4 for a badge over artwork, 16 for a sheet.
enum Corner {
static let standard: CGFloat = 8
static let tight: CGFloat = 4
static let sheet: CGFloat = 16
}