feat(ios): ship iOS SwiftUI client (VoiceCatiOS)
Full SwiftUI app at clients/apple/iOS/VoiceCatiOS.xcodeproj:
- 24 Swift source files: AppState + SessionState (@Observable @MainActor),
AudioSessionManager (AVAudioSession owner + interruption/route handling),
ServerListStore/SavedServer (App Group container + Keychain sharing),
and 14 SwiftUI views covering the full feature set
- NavigationSplitView on iPad, TabView on iPhone (horizontalSizeClass)
- Channel tree via OutlineGroup, user list with context menu admin actions
- PTT via DragGesture(minimumDistance: 0) + @GestureState
- onEvent closures hop to MainActor via Task { @MainActor in ... }
- App Group: group.cat.voice.VoiceCat (shared with future ReplayKit extension)
C ABI: add vc_audio_suspend / vc_audio_resume (AudioEngine::suspend/resume)
called by AudioSessionManager on AVAudioSession interruption events.
XCFramework: add ios-arm64 and ios-arm64-simulator slices to build-xcframework.sh;
Package.swift gains .iOS(.v17) platform; CMakePresets.json adds apple-ios /
apple-ios-sim presets with arm64-ios / arm64-ios-simulator vcpkg triplets.
Verified: xcodebuild -target VoiceCatiOS -sdk iphonesimulator26.5 BUILD SUCCEEDED.
This commit is contained in:
131
clients/apple/iOS/VoiceCatiOS/Views/ChannelTreeView.swift
Normal file
131
clients/apple/iOS/VoiceCatiOS/Views/ChannelTreeView.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
// ChannelNode wraps Channel for OutlineGroup; childrenOrNil must be nil (not empty [])
|
||||
// for leaf channels so OutlineGroup doesn't render expand buttons.
|
||||
struct ChannelNode: Identifiable {
|
||||
let channel: Channel
|
||||
let children: [ChannelNode]?
|
||||
var id: UInt32 { channel.id }
|
||||
}
|
||||
|
||||
struct ChannelTreeView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var showCreateChannel = false
|
||||
@State private var channelPassword = ""
|
||||
@State private var passwordChannelId: UInt32?
|
||||
|
||||
var body: some View {
|
||||
List(channelTree, children: \.children) { node in
|
||||
ChannelRowView(node: node, session: session)
|
||||
.onTapGesture {
|
||||
if node.channel.passwordProtected {
|
||||
passwordChannelId = node.channel.id
|
||||
} else {
|
||||
session.joinChannel(node.channel.id)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
if session.permissions.isAdmin {
|
||||
Button(role: .destructive) {
|
||||
session.deleteChannel(node.channel.id)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.toolbar {
|
||||
if session.permissions.canCreateTempChannel || session.permissions.isAdmin {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showCreateChannel = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Create channel")
|
||||
}
|
||||
}
|
||||
if session.currentChannelId != 0 {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Leave", systemImage: "arrow.left.circle") {
|
||||
session.leaveChannel()
|
||||
}
|
||||
.accessibilityLabel("Leave current channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showCreateChannel) {
|
||||
ChannelEditView(channelId: nil, session: session)
|
||||
}
|
||||
.alert("Channel Password", isPresented: Binding(
|
||||
get: { passwordChannelId != nil },
|
||||
set: { if !$0 { passwordChannelId = nil; channelPassword = "" } }
|
||||
)) {
|
||||
SecureField("Password", text: $channelPassword)
|
||||
.accessibilityLabel("Channel password")
|
||||
Button("Join") {
|
||||
if let cid = passwordChannelId {
|
||||
session.joinChannel(cid, password: channelPassword)
|
||||
}
|
||||
passwordChannelId = nil
|
||||
channelPassword = ""
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
passwordChannelId = nil
|
||||
channelPassword = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var channelTree: [ChannelNode] {
|
||||
buildTree(parentId: 0, channels: session.channels)
|
||||
}
|
||||
|
||||
private func buildTree(parentId: UInt32, channels: [Channel]) -> [ChannelNode] {
|
||||
channels
|
||||
.filter { $0.parentId == parentId }
|
||||
.map { ch in
|
||||
let kids = buildTree(parentId: ch.id, channels: channels)
|
||||
return ChannelNode(channel: ch, children: kids.isEmpty ? nil : kids)
|
||||
}
|
||||
.sorted { $0.channel.name < $1.channel.name }
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChannelRowView: View {
|
||||
let node: ChannelNode
|
||||
let session: SessionState
|
||||
|
||||
var body: some View {
|
||||
let ch = node.channel
|
||||
let isCurrent = session.currentChannelId == ch.id
|
||||
let usersHere = session.users.filter { $0.channelId == ch.id }
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: ch.passwordProtected ? "lock.fill" : "number")
|
||||
.foregroundStyle(isCurrent ? .blue : .secondary)
|
||||
.imageScale(.small)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(ch.name)
|
||||
.fontWeight(isCurrent ? .semibold : .regular)
|
||||
if !ch.topic.isEmpty {
|
||||
Text(ch.topic)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if !usersHere.isEmpty {
|
||||
Text("\(usersHere.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("\(usersHere.count) users")
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(ch.name)\(isCurrent ? ", current" : "")\(ch.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user