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:
153
clients/apple/iOS/VoiceCatiOS/Views/AccountsView.swift
Normal file
153
clients/apple/iOS/VoiceCatiOS/Views/AccountsView.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct AccountsView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var showCreateAccount = false
|
||||
@State private var newUsername = ""
|
||||
@State private var newPassword = ""
|
||||
@State private var accountToDelete: Account?
|
||||
@State private var showResetPassword = false
|
||||
@State private var resetForAccount: Account?
|
||||
@State private var resetPassword = ""
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(session.accounts, id: \.username) { account in
|
||||
AccountRowView(account: account)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
accountToDelete = account
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
Button {
|
||||
resetForAccount = account
|
||||
showResetPassword = true
|
||||
} label: {
|
||||
Label("Reset PW", systemImage: "key")
|
||||
}
|
||||
.tint(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Accounts")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showCreateAccount = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Create account")
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
session.fetchAccountList()
|
||||
}
|
||||
.onAppear {
|
||||
session.fetchAccountList()
|
||||
}
|
||||
.confirmationDialog("Delete account?", isPresented: Binding(
|
||||
get: { accountToDelete != nil },
|
||||
set: { if !$0 { accountToDelete = nil } }
|
||||
)) {
|
||||
if let a = accountToDelete {
|
||||
Button("Delete \(a.username)", role: .destructive) {
|
||||
session.deleteAccount(username: a.username)
|
||||
accountToDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { accountToDelete = nil }
|
||||
}
|
||||
.sheet(isPresented: $showCreateAccount) {
|
||||
CreateAccountSheet(session: session)
|
||||
}
|
||||
.alert("Reset Password", isPresented: $showResetPassword) {
|
||||
SecureField("New password", text: $resetPassword)
|
||||
.accessibilityLabel("New password for account")
|
||||
Button("Reset") {
|
||||
if let a = resetForAccount, !resetPassword.isEmpty {
|
||||
session.resetPassword(username: a.username, newPassword: resetPassword)
|
||||
}
|
||||
resetPassword = ""
|
||||
resetForAccount = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
resetPassword = ""
|
||||
resetForAccount = nil
|
||||
}
|
||||
} message: {
|
||||
Text("Enter a new password for \(resetForAccount?.username ?? "").")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountRowView: View {
|
||||
let account: Account
|
||||
|
||||
private var joinedDate: String {
|
||||
let date = Date(timeIntervalSince1970: Double(account.createdAtUnixMs) / 1000)
|
||||
return date.formatted(.dateTime.year().month().day())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(account.username)
|
||||
.fontWeight(.medium)
|
||||
if account.isAdmin {
|
||||
Text("admin")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(.orange.opacity(0.2), in: Capsule())
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
Text("Created \(joinedDate)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(account.username)\(account.isAdmin ? ", administrator" : ""), created \(joinedDate)")
|
||||
}
|
||||
}
|
||||
|
||||
private struct CreateAccountSheet: View {
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Username")
|
||||
SecureField("Password", text: $password)
|
||||
.textContentType(.newPassword)
|
||||
.accessibilityLabel("Password")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Create Account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Create") {
|
||||
session.createAccount(username: username, password: password)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(username.isEmpty || password.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
clients/apple/iOS/VoiceCatiOS/Views/ActivityLogView.swift
Normal file
49
clients/apple/iOS/VoiceCatiOS/Views/ActivityLogView.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ActivityLogView: View {
|
||||
@Bindable var session: SessionState
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateStyle = .none
|
||||
fmt.timeStyle = .medium
|
||||
return fmt
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
List(session.activityLog) { entry in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text(Self.timeFormatter.string(from: entry.timestamp))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
.frame(width: 64, alignment: .leading)
|
||||
Text(entry.text)
|
||||
.font(.caption)
|
||||
}
|
||||
.id(entry.id)
|
||||
.listRowSeparator(.hidden)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(Self.timeFormatter.string(from: entry.timestamp)): \(entry.text)")
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.onChange(of: session.activityLog.count) { _, _ in
|
||||
if let last = session.activityLog.last {
|
||||
proxy.scrollTo(last.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Activity")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.overlay {
|
||||
if session.activityLog.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Activity",
|
||||
systemImage: "bell.slash",
|
||||
description: Text("Events will appear here as they happen.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
94
clients/apple/iOS/VoiceCatiOS/Views/AddServerView.swift
Normal file
94
clients/apple/iOS/VoiceCatiOS/Views/AddServerView.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddServerView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let editing: SavedServer?
|
||||
|
||||
@State private var host = ""
|
||||
@State private var port = "7878"
|
||||
@State private var authMode = SavedServer.AuthMode.guest
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var savePassword = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Server") {
|
||||
TextField("Hostname or IP", text: $host)
|
||||
.textContentType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Server hostname or IP address")
|
||||
TextField("Port", text: $port)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Port number")
|
||||
}
|
||||
|
||||
Section("Authentication") {
|
||||
Picker("Mode", selection: $authMode) {
|
||||
Text("Guest").tag(SavedServer.AuthMode.guest)
|
||||
Text("Account").tag(SavedServer.AuthMode.password)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.accessibilityLabel("Authentication mode")
|
||||
|
||||
if authMode == .password {
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Username")
|
||||
SecureField("Password (optional)", text: $password)
|
||||
.textContentType(.password)
|
||||
.accessibilityLabel("Password, optional, leave blank to enter at connect time")
|
||||
Toggle("Save password in Keychain", isOn: $savePassword)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(editing == nil ? "Add Server" : "Edit Server")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { save() }
|
||||
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| UInt16(port) == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let s = editing {
|
||||
host = s.host
|
||||
port = "\(s.port)"
|
||||
authMode = s.authMode
|
||||
username = s.savedUsername
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedHost.isEmpty, let portNum = UInt16(port) else { return }
|
||||
|
||||
let pw = (savePassword && authMode == .password && !password.isEmpty) ? password : nil
|
||||
|
||||
if var s = editing {
|
||||
s.host = trimmedHost
|
||||
s.port = portNum
|
||||
s.authMode = authMode
|
||||
s.savedUsername = authMode == .password ? username : ""
|
||||
appState.updateServer(s, password: pw)
|
||||
} else {
|
||||
let s = SavedServer(host: trimmedHost, port: portNum,
|
||||
authMode: authMode,
|
||||
savedUsername: authMode == .password ? username : "")
|
||||
appState.addServer(s, password: pw)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
58
clients/apple/iOS/VoiceCatiOS/Views/BanUserView.swift
Normal file
58
clients/apple/iOS/VoiceCatiOS/Views/BanUserView.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct BanUserView: View {
|
||||
let user: User
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var reason = ""
|
||||
@State private var permanent = true
|
||||
@State private var duration: Double = 60 // minutes
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Ban \(user.nickname)") {
|
||||
TextField("Reason (optional)", text: $reason)
|
||||
.accessibilityLabel("Ban reason, optional")
|
||||
Toggle("Permanent", isOn: $permanent)
|
||||
.accessibilityLabel("Permanent ban")
|
||||
if !permanent {
|
||||
HStack {
|
||||
Text("Duration")
|
||||
Slider(value: $duration, in: 1...10080, step: 1)
|
||||
.accessibilityLabel("Ban duration in minutes")
|
||||
Text(formattedDuration)
|
||||
.monospacedDigit()
|
||||
.frame(width: 60, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Ban User")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Ban", role: .destructive) {
|
||||
let expiresMs: UInt64 = permanent ? 0
|
||||
: UInt64(Date().timeIntervalSince1970 * 1000) + UInt64(duration * 60 * 1000)
|
||||
session.banUser(user.id, reason: reason, expiresUnixMs: expiresMs)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var formattedDuration: String {
|
||||
let mins = Int(duration)
|
||||
if mins < 60 { return "\(mins)m" }
|
||||
let hours = mins / 60
|
||||
if hours < 24 { return "\(hours)h" }
|
||||
return "\(hours / 24)d"
|
||||
}
|
||||
}
|
||||
38
clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift
Normal file
38
clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChannelEditView: View {
|
||||
let channelId: UInt32?
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var name = ""
|
||||
@State private var topic = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Channel Info") {
|
||||
TextField("Name", text: $name)
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityLabel("Channel name")
|
||||
TextField("Topic (optional)", text: $topic)
|
||||
.accessibilityLabel("Channel topic, optional")
|
||||
}
|
||||
}
|
||||
.navigationTitle(channelId == nil ? "New Channel" : "Edit Channel")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
session.createChannel(name: name, topic: topic)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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" : "")")
|
||||
}
|
||||
}
|
||||
94
clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift
Normal file
94
clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct ChatView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var composeText = ""
|
||||
@State private var scope: VoiceCatTextScope = .channel
|
||||
@State private var privateTargetId: UInt32 = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Message list
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(session.messages) { msg in
|
||||
ChatBubble(message: msg)
|
||||
.id(msg.id)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onChange(of: session.messages.count) { _, _ in
|
||||
if let last = session.messages.last {
|
||||
proxy.scrollTo(last.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Compose bar
|
||||
HStack(spacing: 8) {
|
||||
TextField("Message…", text: $composeText, axis: .vertical)
|
||||
.lineLimit(1...5)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityLabel("Message text field")
|
||||
.onSubmit { sendMessage() }
|
||||
|
||||
Button {
|
||||
sendMessage()
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.imageScale(.large)
|
||||
}
|
||||
.disabled(composeText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| session.currentChannelId == 0)
|
||||
.accessibilityLabel("Send message")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
session.sendText(text, scope: .channel)
|
||||
composeText = ""
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatBubble: View {
|
||||
let message: ChatMessage
|
||||
|
||||
private var timeString: String {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateStyle = .none
|
||||
fmt.timeStyle = .short
|
||||
return fmt.string(from: message.timestamp)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Text(message.senderName)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(timeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Text(message.text)
|
||||
.font(.body)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(message.senderName) at \(timeString): \(message.text)")
|
||||
}
|
||||
}
|
||||
72
clients/apple/iOS/VoiceCatiOS/Views/MainView.swift
Normal file
72
clients/apple/iOS/VoiceCatiOS/Views/MainView.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MainView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
var body: some View {
|
||||
if let session = appState.session {
|
||||
if sizeClass == .regular {
|
||||
iPadMainView(session: session)
|
||||
} else {
|
||||
iPhoneMainView(session: session)
|
||||
}
|
||||
} else {
|
||||
ServerListView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPhone layout: TabView
|
||||
|
||||
private struct iPhoneMainView: View {
|
||||
let session: SessionState
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
ChannelTreeView(session: session)
|
||||
.tabItem {
|
||||
Label("Channels", systemImage: "list.bullet.indent")
|
||||
}
|
||||
ChatView(session: session)
|
||||
.tabItem {
|
||||
Label("Chat", systemImage: "message")
|
||||
}
|
||||
ActivityLogView(session: session)
|
||||
.tabItem {
|
||||
Label("Activity", systemImage: "bell")
|
||||
}
|
||||
SettingsView(session: session)
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gear")
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
VoiceControlsView(session: session)
|
||||
.padding(.bottom, 56) // above tab bar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPad layout: NavigationSplitView
|
||||
|
||||
private struct iPadMainView: View {
|
||||
let session: SessionState
|
||||
@State private var columnVisibility = NavigationSplitViewVisibility.all
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
ChannelTreeView(session: session)
|
||||
.navigationTitle("Channels")
|
||||
} content: {
|
||||
UserListView(session: session)
|
||||
.navigationTitle("Users")
|
||||
} detail: {
|
||||
VStack(spacing: 0) {
|
||||
ChatView(session: session)
|
||||
VoiceControlsView(session: session)
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
}
|
||||
}
|
||||
}
|
||||
48
clients/apple/iOS/VoiceCatiOS/Views/MoveUserView.swift
Normal file
48
clients/apple/iOS/VoiceCatiOS/Views/MoveUserView.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct MoveUserView: View {
|
||||
let user: User
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedChannelId: UInt32 = 0
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(session.channels) { channel in
|
||||
HStack {
|
||||
Text(channel.name)
|
||||
Spacer()
|
||||
if channel.id == selectedChannelId {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.blue)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selectedChannelId = channel.id }
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(channel.name)\(channel.id == selectedChannelId ? ", selected" : "")")
|
||||
.accessibilityAddTraits(channel.id == selectedChannelId ? .isSelected : [])
|
||||
}
|
||||
.navigationTitle("Move \(user.nickname)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Move") {
|
||||
session.moveUser(user.id, toChannel: selectedChannelId)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(selectedChannelId == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
selectedChannelId = user.channelId
|
||||
}
|
||||
}
|
||||
}
|
||||
65
clients/apple/iOS/VoiceCatiOS/Views/PasswordPromptView.swift
Normal file
65
clients/apple/iOS/VoiceCatiOS/Views/PasswordPromptView.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PasswordPromptView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
if let server = appState.connectingServer {
|
||||
Text("Connecting to \(server.displayString)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if appState.connectingServer?.authMode == .password {
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Username")
|
||||
}
|
||||
SecureField("Password", text: $password)
|
||||
.textContentType(.password)
|
||||
.accessibilityLabel("Password")
|
||||
}
|
||||
|
||||
if !appState.connectStatus.isEmpty && appState.connectStatus.lowercased().contains("failed") {
|
||||
Section {
|
||||
Text(appState.connectStatus)
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityLabel("Error: \(appState.connectStatus)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sign In")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
appState.cancelConnect()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Connect") {
|
||||
dismiss()
|
||||
let uname = appState.connectingServer?.savedUsername.isEmpty == false
|
||||
? appState.connectingServer!.savedUsername
|
||||
: username
|
||||
appState.authenticateUser(username: uname, password: password)
|
||||
}
|
||||
.disabled(password.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
username = appState.connectingServer?.savedUsername ?? ""
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
}
|
||||
71
clients/apple/iOS/VoiceCatiOS/Views/PerUserTuningView.swift
Normal file
71
clients/apple/iOS/VoiceCatiOS/Views/PerUserTuningView.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct PerUserTuningView: View {
|
||||
let user: User
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var gain: Float = 1.0
|
||||
@State private var muted = false
|
||||
@State private var noiseReduction = false
|
||||
|
||||
private var streamsForUser: [StreamSummary] {
|
||||
session.client.listUserStreams(user.id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Volume") {
|
||||
HStack {
|
||||
Text("Gain")
|
||||
Slider(value: $gain, in: 0...2, step: 0.05) { _ in
|
||||
applyToAllStreams()
|
||||
}
|
||||
.accessibilityLabel("Volume gain for \(user.nickname)")
|
||||
Text(String(format: "%.0f%%", gain * 100))
|
||||
.monospacedDigit()
|
||||
.frame(width: 44, alignment: .trailing)
|
||||
}
|
||||
Toggle("Mute", isOn: $muted)
|
||||
.onChange(of: muted) { _, _ in applyToAllStreams() }
|
||||
.accessibilityLabel("Mute \(user.nickname)")
|
||||
}
|
||||
|
||||
Section("Audio Processing") {
|
||||
Toggle("Noise Reduction", isOn: $noiseReduction)
|
||||
.onChange(of: noiseReduction) { _, _ in applyToAllStreams() }
|
||||
.accessibilityLabel("Noise reduction for \(user.nickname)")
|
||||
}
|
||||
}
|
||||
.navigationTitle(user.nickname)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Load from first stream if available
|
||||
let streams = streamsForUser
|
||||
if let first = streams.first {
|
||||
let (_, state) = session.client.getRemoteStream(userId: user.id, streamId: first.id)
|
||||
if let s = state {
|
||||
gain = s.gain
|
||||
muted = s.muted
|
||||
noiseReduction = s.noiseReduction
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyToAllStreams() {
|
||||
for stream in streamsForUser {
|
||||
session.client.setRemoteStream(
|
||||
userId: user.id, streamId: stream.id,
|
||||
gain: gain, muted: muted, noiseReduction: noiseReduction)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
clients/apple/iOS/VoiceCatiOS/Views/PermissionsView.swift
Normal file
63
clients/apple/iOS/VoiceCatiOS/Views/PermissionsView.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct PermissionsView: View {
|
||||
let user: User
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var canCreateTempChannel = false
|
||||
@State private var canKick = false
|
||||
@State private var canBan = false
|
||||
@State private var canMoveUsers = false
|
||||
@State private var canAdminAccounts = false
|
||||
@State private var isAdmin = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Permissions for \(user.nickname)") {
|
||||
Toggle("Create Temp Channels", isOn: $canCreateTempChannel)
|
||||
.accessibilityLabel("Can create temporary channels")
|
||||
Toggle("Kick Users", isOn: $canKick)
|
||||
.accessibilityLabel("Can kick users")
|
||||
Toggle("Ban Users", isOn: $canBan)
|
||||
.accessibilityLabel("Can ban users")
|
||||
Toggle("Move Users", isOn: $canMoveUsers)
|
||||
.accessibilityLabel("Can move users between channels")
|
||||
Toggle("Manage Accounts", isOn: $canAdminAccounts)
|
||||
.accessibilityLabel("Can manage server accounts")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Administrator", isOn: $isAdmin)
|
||||
.foregroundStyle(isAdmin ? .orange : .primary)
|
||||
.accessibilityLabel("Full administrator access")
|
||||
} footer: {
|
||||
Text("Administrators bypass all permission checks.")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Permissions")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
let perms = Permissions(
|
||||
canCreateTempChannel: canCreateTempChannel,
|
||||
canKick: canKick,
|
||||
canBan: canBan,
|
||||
canMoveUsers: canMoveUsers,
|
||||
canAdminAccounts: canAdminAccounts,
|
||||
isAdmin: isAdmin)
|
||||
session.setPermissions(user.id, perms: perms)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
clients/apple/iOS/VoiceCatiOS/Views/ServerIdentityView.swift
Normal file
75
clients/apple/iOS/VoiceCatiOS/Views/ServerIdentityView.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct ServerIdentityView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let identity: PendingIdentity
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if identity.tofuStatus == .mismatch {
|
||||
Label("Server Identity Mismatch", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityLabel("Warning: server identity mismatch")
|
||||
Text("The server's identity has changed since your last connection. This may indicate a man-in-the-middle attack, or that the server was reinstalled. Do NOT accept unless you know why the identity changed.")
|
||||
.foregroundStyle(.primary)
|
||||
} else {
|
||||
Label("New Server Identity", systemImage: "lock.badge.questionmark")
|
||||
.font(.headline)
|
||||
.accessibilityLabel("New server identity")
|
||||
Text("This is the first time you are connecting to this server. Verify the fingerprint below with the server administrator before accepting.")
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Server fingerprint")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(identity.displayText.isEmpty ? "(not available)" : identity.displayText)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.accessibilityLabel("Server fingerprint: \(identity.displayText)")
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
Spacer(minLength: 24)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
dismiss()
|
||||
appState.confirmServerIdentity(accept: true)
|
||||
} label: {
|
||||
Text("Accept")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(identity.tofuStatus == .mismatch ? .orange : .blue)
|
||||
.accessibilityLabel("Accept server identity and continue")
|
||||
|
||||
Button(role: .destructive) {
|
||||
dismiss()
|
||||
appState.confirmServerIdentity(accept: false)
|
||||
} label: {
|
||||
Text("Reject — Disconnect")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel("Reject server identity and disconnect")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Server Identity")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
}
|
||||
120
clients/apple/iOS/VoiceCatiOS/Views/ServerListView.swift
Normal file
120
clients/apple/iOS/VoiceCatiOS/Views/ServerListView.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ServerListView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var serverToDelete: SavedServer?
|
||||
|
||||
var body: some View {
|
||||
@Bindable var state = appState
|
||||
NavigationStack {
|
||||
Group {
|
||||
if appState.servers.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Servers",
|
||||
systemImage: "server.rack",
|
||||
description: Text("Tap + to add a server.")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(appState.servers) { server in
|
||||
Button {
|
||||
appState.connectTo(server)
|
||||
} label: {
|
||||
ServerRowView(server: server)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
serverToDelete = server
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
Button {
|
||||
appState.editingServer = server
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Servers")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
appState.showAddServer = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Add server")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $state.showAddServer) {
|
||||
AddServerView(editing: nil)
|
||||
}
|
||||
.sheet(item: $state.editingServer) { server in
|
||||
AddServerView(editing: server)
|
||||
}
|
||||
.sheet(item: $state.pendingIdentity) { identity in
|
||||
ServerIdentityView(identity: identity)
|
||||
}
|
||||
.sheet(isPresented: $state.showPasswordPrompt) {
|
||||
PasswordPromptView()
|
||||
}
|
||||
.overlay {
|
||||
if appState.isConnecting {
|
||||
ConnectingOverlay()
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Delete server?", isPresented: Binding(
|
||||
get: { serverToDelete != nil },
|
||||
set: { if !$0 { serverToDelete = nil } }
|
||||
)) {
|
||||
if let s = serverToDelete {
|
||||
Button("Delete \(s.displayString)", role: .destructive) {
|
||||
appState.removeServer(s)
|
||||
serverToDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { serverToDelete = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ServerRowView: View {
|
||||
let server: SavedServer
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(server.displayString)
|
||||
.font(.body)
|
||||
Text(server.authMode == .guest
|
||||
? "Guest"
|
||||
: "Account: \(server.savedUsername)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(server.displayString), \(server.authMode == .guest ? "guest" : "account \(server.savedUsername)")")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectingOverlay: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.3).ignoresSafeArea()
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text(appState.connectStatus)
|
||||
.foregroundStyle(.white)
|
||||
Button("Cancel") { appState.cancelConnect() }
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.white)
|
||||
}
|
||||
.padding(24)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
}
|
||||
94
clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Normal file
94
clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Bindable var session: SessionState
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Voice input section
|
||||
Section("Voice Input") {
|
||||
Picker("Input Mode", selection: Binding(
|
||||
get: { session.voiceState.inputMode },
|
||||
set: { session.setInputMode($0) }
|
||||
)) {
|
||||
Text("Voice Activation").tag(VoiceCatInputMode.voiceActivation)
|
||||
Text("Push to Talk").tag(VoiceCatInputMode.pushToTalk)
|
||||
Text("Always On").tag(VoiceCatInputMode.alwaysOn)
|
||||
}
|
||||
.accessibilityLabel("Voice input mode")
|
||||
|
||||
if session.voiceState.inputMode == .voiceActivation {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("VAD Threshold: \(String(format: "%.3f", session.voiceState.vadThreshold))")
|
||||
.font(.caption)
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(session.voiceState.vadThreshold) },
|
||||
set: { session.setVadThreshold(Float($0)) }
|
||||
),
|
||||
in: 0.001...0.1, step: 0.001
|
||||
)
|
||||
.accessibilityLabel("Voice activation threshold")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audio device section
|
||||
if !session.devices.isEmpty {
|
||||
Section("Input Device") {
|
||||
Picker("Microphone", selection: Binding(
|
||||
get: { session.voiceState.currentDeviceId ?? "" },
|
||||
set: { id in
|
||||
session.voiceState.currentDeviceId = id.isEmpty ? nil : id
|
||||
if session.voiceState.localStreamId != 0 {
|
||||
session.client.setInputDevice(
|
||||
streamId: session.voiceState.localStreamId,
|
||||
deviceId: id.isEmpty ? nil : id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
Text("Default").tag("")
|
||||
ForEach(session.devices) { dev in
|
||||
Text(dev.name).tag(dev.id)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Microphone selection")
|
||||
}
|
||||
}
|
||||
|
||||
// Admin section
|
||||
if session.permissions.canAdminAccounts || session.permissions.isAdmin {
|
||||
Section("Administration") {
|
||||
NavigationLink("Manage Accounts") {
|
||||
AccountsView(session: session)
|
||||
}
|
||||
.accessibilityLabel("Manage server accounts")
|
||||
}
|
||||
}
|
||||
|
||||
// Server info
|
||||
Section("Server") {
|
||||
Button(role: .destructive) {
|
||||
session.stopMicStream()
|
||||
appState.disconnect()
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "phone.down")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.accessibilityLabel("Disconnect from server")
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
Text(VoiceCatClient.versionString)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("Version: \(VoiceCatClient.versionString)")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
144
clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift
Normal file
144
clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct UserListView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var selectedUser: User?
|
||||
@State private var showPerUserTuning = false
|
||||
@State private var showBanUser = false
|
||||
@State private var showMoveUser = false
|
||||
@State private var showPermissions = false
|
||||
|
||||
var body: some View {
|
||||
let channelUsers = session.currentChannelId == 0
|
||||
? session.users
|
||||
: session.users.filter { $0.channelId == session.currentChannelId }
|
||||
|
||||
List(channelUsers) { user in
|
||||
UserRowView(user: user, isSelf: user.id == session.selfUserId)
|
||||
.contextMenu {
|
||||
userContextMenu(user: user)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.overlay {
|
||||
if channelUsers.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Users",
|
||||
systemImage: "person.slash",
|
||||
description: Text(session.currentChannelId == 0 ? "Join a channel to see users." : "No one else here yet.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedUser) { user in
|
||||
if showPerUserTuning {
|
||||
PerUserTuningView(user: user, session: session)
|
||||
} else if showBanUser {
|
||||
BanUserView(user: user, session: session)
|
||||
} else if showMoveUser {
|
||||
MoveUserView(user: user, session: session)
|
||||
} else if showPermissions {
|
||||
PermissionsView(user: user, session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func userContextMenu(user: User) -> some View {
|
||||
if user.id != session.selfUserId {
|
||||
Button {
|
||||
selectedUser = user
|
||||
showPerUserTuning = true
|
||||
showBanUser = false; showMoveUser = false; showPermissions = false
|
||||
} label: {
|
||||
Label("Volume / NR", systemImage: "speaker.wave.2")
|
||||
}
|
||||
if session.permissions.canKick || session.permissions.isAdmin {
|
||||
Divider()
|
||||
Button {
|
||||
session.kickUser(user.id, reason: "")
|
||||
} label: {
|
||||
Label("Kick", systemImage: "person.fill.xmark")
|
||||
}
|
||||
if session.permissions.canBan || session.permissions.isAdmin {
|
||||
Button(role: .destructive) {
|
||||
selectedUser = user
|
||||
showBanUser = true
|
||||
showPerUserTuning = false; showMoveUser = false; showPermissions = false
|
||||
} label: {
|
||||
Label("Ban…", systemImage: "nosign")
|
||||
}
|
||||
}
|
||||
}
|
||||
if session.permissions.canMoveUsers || session.permissions.isAdmin {
|
||||
Button {
|
||||
selectedUser = user
|
||||
showMoveUser = true
|
||||
showPerUserTuning = false; showBanUser = false; showPermissions = false
|
||||
} label: {
|
||||
Label("Move to channel…", systemImage: "arrow.right.circle")
|
||||
}
|
||||
}
|
||||
if session.permissions.isAdmin {
|
||||
Divider()
|
||||
let muted = user.serverMuted
|
||||
Button {
|
||||
session.setServerMute(user.id, muted: !muted, deafened: user.serverDeafened)
|
||||
} label: {
|
||||
Label(muted ? "Unmute" : "Server Mute", systemImage: muted ? "mic" : "mic.slash")
|
||||
}
|
||||
Button {
|
||||
selectedUser = user
|
||||
showPermissions = true
|
||||
showPerUserTuning = false; showBanUser = false; showMoveUser = false
|
||||
} label: {
|
||||
Label("Permissions…", systemImage: "lock.shield")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct UserRowView: View {
|
||||
let user: User
|
||||
let isSelf: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: user.selfMicMuted || user.serverMuted ? "mic.slash.fill" : "mic.fill")
|
||||
.foregroundStyle(user.selfMicMuted || user.serverMuted ? .red : .green)
|
||||
.imageScale(.small)
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Text(user.nickname)
|
||||
.fontWeight(isSelf ? .semibold : .regular)
|
||||
if isSelf {
|
||||
Text("(you)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if user.isGuest {
|
||||
Text("guest")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if user.serverMuted || user.serverDeafened {
|
||||
Text(user.serverDeafened ? "server deafened" : "server muted")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if user.selfDeafened {
|
||||
Image(systemName: "headphones.slash")
|
||||
.imageScale(.small)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(user.nickname)\(isSelf ? ", you" : "")\(user.isGuest ? ", guest" : "")\(user.selfMicMuted ? ", muted" : "")\(user.serverMuted ? ", server muted" : "")")
|
||||
}
|
||||
}
|
||||
130
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
130
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct VoiceControlsView: View {
|
||||
@Bindable var session: SessionState
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 20) {
|
||||
// Mic toggle / PTT button
|
||||
if session.voiceState.inputMode == .pushToTalk {
|
||||
PTTButton(session: session)
|
||||
} else {
|
||||
Button {
|
||||
if session.voiceState.micActive {
|
||||
session.stopMicStream()
|
||||
} else {
|
||||
session.startMicStream()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: session.voiceState.micActive ? "mic.fill" : "mic.slash.fill")
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(session.voiceState.micActive ? Color.green : Color(.systemGray4), in: Circle())
|
||||
.foregroundStyle(session.voiceState.micActive ? .white : .primary)
|
||||
}
|
||||
.disabled(session.currentChannelId == 0)
|
||||
.accessibilityLabel(session.voiceState.micActive ? "Stop microphone" : "Start microphone")
|
||||
}
|
||||
|
||||
// Level meter
|
||||
LevelMeterView(level: session.voiceState.level)
|
||||
.frame(width: 80, height: 8)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Self mute
|
||||
Button {
|
||||
session.setMute(!session.voiceState.selfMuted, deafened: session.voiceState.selfDeafened)
|
||||
} label: {
|
||||
Image(systemName: session.voiceState.selfMuted ? "mic.slash" : "mic")
|
||||
.font(.title3)
|
||||
.foregroundStyle(session.voiceState.selfMuted ? .red : .primary)
|
||||
}
|
||||
.accessibilityLabel(session.voiceState.selfMuted ? "Unmute microphone" : "Mute microphone")
|
||||
|
||||
// Self deafen
|
||||
Button {
|
||||
session.setMute(session.voiceState.selfMuted, deafened: !session.voiceState.selfDeafened)
|
||||
} label: {
|
||||
Image(systemName: session.voiceState.selfDeafened ? "headphones.slash" : "headphones")
|
||||
.font(.title3)
|
||||
.foregroundStyle(session.voiceState.selfDeafened ? .red : .primary)
|
||||
}
|
||||
.accessibilityLabel(session.voiceState.selfDeafened ? "Undeafen" : "Deafen")
|
||||
|
||||
// Disconnect
|
||||
Button(role: .destructive) {
|
||||
session.stopMicStream()
|
||||
session.client.disconnect()
|
||||
} label: {
|
||||
Image(systemName: "phone.down.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.accessibilityLabel("Disconnect from server")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PTT Button (DragGesture instead of NSEvent on iOS)
|
||||
|
||||
private struct PTTButton: View {
|
||||
@Bindable var session: SessionState
|
||||
@GestureState private var isPressing = false
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(isPressing ? Color.blue : Color(.systemGray4))
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay {
|
||||
Image(systemName: "mic.fill")
|
||||
.foregroundStyle(isPressing ? .white : .primary)
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.updating($isPressing) { _, state, _ in state = true }
|
||||
.onChanged { _ in
|
||||
if !isPressing { return }
|
||||
if !session.voiceState.micActive { session.startMicStream() }
|
||||
session.setPushToTalk(true)
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
}
|
||||
.onEnded { _ in
|
||||
session.setPushToTalk(false)
|
||||
session.stopMicStream()
|
||||
}
|
||||
)
|
||||
.accessibilityLabel("Push to talk, hold to transmit")
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Level Meter
|
||||
|
||||
private struct LevelMeterView: View {
|
||||
let level: Float
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray5))
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(levelColor)
|
||||
.frame(width: geo.size.width * CGFloat(min(level * 10, 1.0)))
|
||||
.animation(.linear(duration: 0.05), value: level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var levelColor: Color {
|
||||
if level > 0.15 { return .orange }
|
||||
if level > 0.05 { return .green }
|
||||
return .green.opacity(0.5)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user