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.
95 lines
3.6 KiB
Swift
95 lines
3.6 KiB
Swift
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()
|
|
}
|
|
}
|