Add iOS app with CarPlay and Siri
SwiftUI port of the Android app under ios/, with CarPlay browse and Now Playing, Siri play-media intents, and a build-ipa.sh script that makes an unsigned IPA for sideloading. README gets an iOS section and the vibecoded disclaimer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45bc0eefe0
commit
fdf9e970b2
45 files changed
+9822
No files matched your search
@@ -0,0 +1,164 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SheetAction: Identifiable {
|
||||
let id = UUID()
|
||||
var title: String
|
||||
var icon: String?
|
||||
var destructive = false
|
||||
var run: () -> Void
|
||||
|
||||
init(_ title: String, icon: String? = nil, destructive: Bool = false, _ run: @escaping () -> Void) {
|
||||
self.title = title; self.icon = icon; self.destructive = destructive; self.run = run
|
||||
}
|
||||
}
|
||||
|
||||
/// Action sheets, alerts, text prompts and the HUD, shown from anywhere. They're drawn by
|
||||
/// [OverlayHost]: one under the tabs and one inside the Now Playing sheet, and only the
|
||||
/// topmost of those shows them.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Overlays {
|
||||
struct Actions { var title: String; var items: [SheetAction] }
|
||||
struct Confirm { var title: String; var message: String?; var button: String; var destructive: Bool; var run: () -> Void }
|
||||
struct Prompt { var title: String; var message: String?; var initial: String; var button: String; var placeholder: String; var run: (String) -> Void }
|
||||
struct Hud: Equatable { let id = UUID(); var text: String; var icon: String }
|
||||
|
||||
var actions: Actions?
|
||||
var confirm: Confirm?
|
||||
var prompt: Prompt?
|
||||
var hud: Hud?
|
||||
/// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's.
|
||||
var level = 0
|
||||
|
||||
private var hudTask: Task<Void, Never>?
|
||||
|
||||
private var showing: Bool { actions != nil || confirm != nil || prompt != nil }
|
||||
|
||||
/// Presents after the current dialog has gone, since SwiftUI drops one shown on top of another.
|
||||
private func present(_ f: @escaping () -> Void) {
|
||||
if showing {
|
||||
actions = nil; confirm = nil; prompt = nil
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 450_000_000)
|
||||
f()
|
||||
}
|
||||
} else {
|
||||
f()
|
||||
}
|
||||
}
|
||||
|
||||
func actions(_ title: String, _ items: [SheetAction]) {
|
||||
present { self.actions = Actions(title: title, items: items) }
|
||||
}
|
||||
|
||||
func confirm(_ title: String, _ message: String?, _ button: String, destructive: Bool = true, _ run: @escaping () -> Void) {
|
||||
present { self.confirm = Confirm(title: title, message: message, button: button, destructive: destructive, run: run) }
|
||||
}
|
||||
|
||||
func prompt(_ title: String, _ message: String?, initial: String = "", _ button: String, placeholder: String = "", _ run: @escaping (String) -> Void) {
|
||||
present { self.prompt = Prompt(title: title, message: message, initial: initial, button: button, placeholder: placeholder, run: run) }
|
||||
}
|
||||
|
||||
func hud(_ text: String, _ icon: String = "checkmark") {
|
||||
hudTask?.cancel()
|
||||
withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) }
|
||||
let secs = text.count > 40 ? 2.8 : 1.4
|
||||
hudTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(secs * 1e9))
|
||||
if Task.isCancelled { return }
|
||||
withAnimation(.easeIn(duration: 0.25)) { self.hud = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OverlayHost: ViewModifier {
|
||||
let overlays: Overlays
|
||||
let level: Int
|
||||
@State private var text = ""
|
||||
|
||||
private var mine: Bool { overlays.level == level }
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
if mine, let h = overlays.hud {
|
||||
HudView(hud: h).transition(.opacity.combined(with: .scale(scale: 0.9))).allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(overlays.actions?.title ?? "", isPresented: binding(\.actions), titleVisibility: .visible, presenting: overlays.actions) { a in
|
||||
ForEach(a.items) { item in
|
||||
Button(item.title, role: item.destructive ? .destructive : nil) { item.run() }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.alert(overlays.confirm?.title ?? "", isPresented: binding(\.confirm), presenting: overlays.confirm) { c in
|
||||
Button(c.button, role: c.destructive ? .destructive : nil) { c.run() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { c in
|
||||
if let m = c.message { Text(m) }
|
||||
}
|
||||
.alert(overlays.prompt?.title ?? "", isPresented: binding(\.prompt), presenting: overlays.prompt) { p in
|
||||
TextField(p.placeholder, text: $text).autocorrectionDisabled()
|
||||
Button(p.button) {
|
||||
let v = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !v.isEmpty { p.run(v) }
|
||||
}
|
||||
.disabled(text.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { p in
|
||||
if let m = p.message { Text(m) }
|
||||
}
|
||||
.onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" }
|
||||
}
|
||||
|
||||
private func binding<T>(_ kp: ReferenceWritableKeyPath<Overlays, T?>) -> Binding<Bool> {
|
||||
Binding(get: { mine && overlays[keyPath: kp] != nil }, set: { if !$0 { overlays[keyPath: kp] = nil } })
|
||||
}
|
||||
}
|
||||
|
||||
private struct HudView: View {
|
||||
let hud: Overlays.Hud
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: hud.icon).font(.system(size: 40, weight: .medium))
|
||||
Text(hud.text).font(.subheadline.weight(.semibold)).multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(minWidth: 150, minHeight: 150)
|
||||
.frame(maxWidth: 240)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func overlayHost(_ o: Overlays, level: Int) -> some View { modifier(OverlayHost(overlays: o, level: level)) }
|
||||
|
||||
/// The same actions on long-press as the ••• menu.
|
||||
func actionsMenu(_ items: [SheetAction]) -> some View {
|
||||
contextMenu { ActionButtons(items: items) }
|
||||
}
|
||||
}
|
||||
|
||||
struct ActionButtons: View {
|
||||
let items: [SheetAction]
|
||||
var body: some View {
|
||||
ForEach(items) { item in
|
||||
Button(role: item.destructive ? .destructive : nil) { item.run() } label: {
|
||||
if let i = item.icon { Label(item.title, systemImage: i) } else { Text(item.title) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ••• button, as a native menu.
|
||||
struct MoreMenu: View {
|
||||
let items: [SheetAction]
|
||||
var size: CGFloat = 17
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Menu { ActionButtons(items: items) } label: {
|
||||
Image(systemName: "ellipsis").font(.system(size: size, weight: .semibold))
|
||||
.frame(width: 34, height: 34).contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user