feat: consolidate iOS with API and web preserving history
Rebuild & Deploy / rebuild (push) Failing after 23s
Rebuild & Deploy / rebuild (push) Failing after 23s
This commit is contained in:
commit
d735ee72fa
102 files changed
+13077
-258
No files matched your search
@@ -0,0 +1,261 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
/// The current voice session, directly observed so the Apply button's
|
||||
/// disabled state and help text update reactively when the session
|
||||
/// transitions (e.g. `.tearingDown` → `.failed`).
|
||||
@ObservedObject var session: VoiceSession
|
||||
/// Local draft of the server URL. Only written to `settings` when the user
|
||||
/// explicitly applies a value that passes validation — keystrokes never
|
||||
/// reach storage or the network layer.
|
||||
@State private var urlText: String = ""
|
||||
@State private var urlError: String?
|
||||
@State private var showVoicePicker = false
|
||||
@State private var savedURLConfirmation: String?
|
||||
@State private var pendingServerChange: String?
|
||||
@FocusState private var urlFocused: Bool
|
||||
init(settings: SettingsStore, session: VoiceSession) {
|
||||
self.settings = settings
|
||||
self.session = session
|
||||
}
|
||||
|
||||
/// Whether the current session is safe to replace. Apply is permitted for
|
||||
/// `.idle` (no active call) and `.failed` (fully torn down after failure —
|
||||
/// the user needs to correct an unreachable server without relaunching).
|
||||
/// Disabled during `.tearingDown`, `.connecting`, `.waitingAgent`, and
|
||||
/// `.ready` to avoid disconnecting an active call or racing cleanup.
|
||||
private var canApplyServerURL: Bool {
|
||||
session.state == .idle || session.state.isFailed
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Button {
|
||||
showVoicePicker = true
|
||||
} label: {
|
||||
LabeledContent("Speaking voice", value: Voices.display(for: settings.voiceID) ?? settings.voiceID)
|
||||
}
|
||||
Picker("Conversation mode", selection: $settings.textMode) {
|
||||
Text("Voice").tag(false)
|
||||
Text("Type · Microphone off").tag(true)
|
||||
}
|
||||
} header: {
|
||||
Text("Make it yours")
|
||||
} footer: {
|
||||
Text("Voice and typing preferences are saved on this iPhone. You can switch modes from the Talk tab.")
|
||||
}
|
||||
Section {
|
||||
Text("Server URL")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
TextField(SettingsStore.defaultServerURL, text: $urlText)
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Server URL")
|
||||
.focused($urlFocused)
|
||||
.onAppear { urlText = settings.serverURL }
|
||||
.onChange(of: urlText) { _, newValue in
|
||||
// Keep confirmation across the asynchronous onChange
|
||||
// caused by successful whitespace normalization.
|
||||
guard newValue != savedURLConfirmation else { return }
|
||||
savedURLConfirmation = nil
|
||||
urlError = nil
|
||||
}
|
||||
.overlay(alignment: .trailing) {
|
||||
if savedURLConfirmation != nil {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.padding(.trailing, 8)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
if let urlError {
|
||||
Label(urlError, systemImage: "exclamationmark.circle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Nearlight.alert)
|
||||
.accessibilityIdentifier("settings.server.error")
|
||||
} else if savedURLConfirmation != nil {
|
||||
Text("Server address saved.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.accessibilityIdentifier("settings.server.saved")
|
||||
}
|
||||
Button("Change server") { requestServerURLChange() }
|
||||
.disabled(!canApplyServerURL || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || urlText == settings.serverURL)
|
||||
if !canApplyServerURL {
|
||||
Text(session.state == .tearingDown
|
||||
? "Waiting for failure cleanup to finish…"
|
||||
: "End the conversation before changing servers.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
}
|
||||
NavigationLink("Connection test") {
|
||||
ConnectionTestView(settings: settings, bearerToken: session.bootstrapBearerToken)
|
||||
}
|
||||
} header: {
|
||||
Text("Connection")
|
||||
} footer: {
|
||||
Text("Hope runs on your own server. Your iPhone needs to be on the same network or a network that can reach it.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Nearlight.ground)
|
||||
.tint(Nearlight.rosewood)
|
||||
.sheet(isPresented: $showVoicePicker) {
|
||||
VoicePickerView(settings: settings, session: session)
|
||||
}
|
||||
.confirmationDialog("Change Hope server?", isPresented: Binding(get: { pendingServerChange != nil }, set: { if !$0 { pendingServerChange = nil } }), titleVisibility: .visible) {
|
||||
Button("Change server and sign out", role: .destructive) {
|
||||
if let pendingServerChange { applyServerURL(pendingServerChange) }
|
||||
pendingServerChange = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingServerChange = nil }
|
||||
} message: {
|
||||
Text("Changing servers signs you out and ends the current session. Conversations and account data remain with the current server.")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Validates the draft and persists it only on success. On failure the
|
||||
/// draft stays in the field with an inline error; `settings.serverURL` is
|
||||
/// left untouched so the active session keeps using its original URL.
|
||||
/// Guarded: only applies when the session is idle (no active call).
|
||||
private func requestServerURLChange() {
|
||||
savedURLConfirmation = nil
|
||||
guard canApplyServerURL else { urlError = "End the conversation before changing servers."; return }
|
||||
let trimmed = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { urlError = "Enter a server URL."; return }
|
||||
switch ServerConfig.parse(trimmed) {
|
||||
case .success(let config):
|
||||
pendingServerChange = config.baseURL.absoluteString
|
||||
urlError = nil
|
||||
case .failure(let error):
|
||||
urlError = error.localizedDescription
|
||||
urlFocused = true
|
||||
}
|
||||
}
|
||||
private func applyServerURL(_ canonicalURL: String) {
|
||||
settings.serverURL = canonicalURL
|
||||
urlText = canonicalURL
|
||||
savedURLConfirmation = canonicalURL
|
||||
urlError = nil
|
||||
urlFocused = false
|
||||
}
|
||||
}
|
||||
|
||||
struct VoicePickerView: View {
|
||||
@ObservedObject var settings: SettingsStore
|
||||
@ObservedObject var session: VoiceSession
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var voices = Voices.all
|
||||
@State private var search = ""
|
||||
@State private var loading = false
|
||||
@State private var catalogMessage: String?
|
||||
@State private var catalogAvailable = false
|
||||
@State private var requestID = UUID()
|
||||
@State private var applyingVoiceID: String?
|
||||
@State private var voiceError: String?
|
||||
|
||||
private var filteredVoices: [VoiceOption] {
|
||||
let query = search.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return voices.filter { query.isEmpty || "\($0.name) \($0.details)".localizedCaseInsensitiveContains(query) }
|
||||
}
|
||||
|
||||
@MainActor private func loadVoices() async {
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
let id = UUID()
|
||||
requestID = id
|
||||
defer { if requestID == id { loading = false } }
|
||||
do {
|
||||
guard case .success(let config) = ServerConfig.parse(settings.serverURL) else { throw AuthError.invalidResponse }
|
||||
let response = try await AuthClient(config: config).voices(token: session.bootstrapBearerToken)
|
||||
try Task.checkCancellation()
|
||||
guard requestID == id else { return }
|
||||
let valid = response.voices.filter { $0.id.range(of: "^[A-Za-z0-9:_-]{1,120}$", options: .regularExpression) != nil }
|
||||
guard !valid.isEmpty else { throw AuthError.invalidResponse }
|
||||
voices = valid
|
||||
catalogAvailable = response.catalogAvailable
|
||||
catalogMessage = response.catalogAvailable ? nil : response.message
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
guard !Task.isCancelled, requestID == id else { return }
|
||||
catalogAvailable = false
|
||||
catalogMessage = "Couldn’t load all English voices. Your current choices remain available. Check your sign-in and server update, then retry."
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Text("Ava Dragon HD Omni is the recommended female default. Search by name, locale, gender, or model.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if loading { ProgressView("Loading English voices…") }
|
||||
if let catalogMessage { Text(catalogMessage).font(.footnote).foregroundStyle(.secondary) }
|
||||
if let voiceError { Label(voiceError, systemImage: "exclamationmark.circle").font(.footnote).foregroundStyle(.red) }
|
||||
if !catalogAvailable && !loading {
|
||||
Button("Retry voice catalog") { Task { await loadVoices() } }
|
||||
}
|
||||
}
|
||||
if filteredVoices.isEmpty {
|
||||
Text("No matching voices. Try another name or locale.").foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(filteredVoices, id: \.id) { voice in
|
||||
Button {
|
||||
guard applyingVoiceID == nil else { return }
|
||||
applyingVoiceID = voice.id
|
||||
voiceError = nil
|
||||
Task {
|
||||
let applied = await session.selectVoice(voice.id)
|
||||
applyingVoiceID = nil
|
||||
if applied { dismiss() }
|
||||
else { voiceError = "Couldn’t apply this voice to the live conversation. Your previous voice is still selected." }
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(voice.name)
|
||||
Text(voice.details).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if settings.voiceID == voice.id {
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(Nearlight.rosewood)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(applyingVoiceID != nil)
|
||||
.accessibilityAddTraits(settings.voiceID == voice.id ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.navigationTitle("English voices")
|
||||
.searchable(text: $search, prompt: "Name, locale, gender, or Omni")
|
||||
.task { await loadVoices() }
|
||||
.onDisappear { requestID = UUID(); loading = false }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let previewSettings = SettingsStore()
|
||||
guard case .success(let previewConfig) = ServerConfig.parse(SettingsStore.defaultServerURL) else {
|
||||
return AnyView(Text("Invalid default server URL"))
|
||||
}
|
||||
let previewSession = VoiceSession(config: previewConfig, tokenClient: TokenClient(config: previewConfig), settings: previewSettings)
|
||||
return AnyView(NavigationStack {
|
||||
SettingsView(settings: previewSettings, session: previewSession)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user