feat: apply pink Opal design with reactive square persona
This commit is contained in:
1 parent
5b41b3924a
commit
74d2b63614
8 files changed
+199
-57
No files matched your search
@@ -50,11 +50,19 @@ struct ConversationView: View {
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
NavigationStack {
|
||||
ConversationContent(settings: settings, session: session)
|
||||
ConversationContent(settings: settings, session: session, displayName: user.displayName ?? user.username)
|
||||
.toolbar {
|
||||
settingsLink
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Sign out") { Task { await auth.logout { await session.disconnect() } } }
|
||||
Menu {
|
||||
Button("Sign out", role: .destructive) {
|
||||
Task { await auth.logout { await session.disconnect() } }
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
}
|
||||
.accessibilityLabel("Account")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +173,7 @@ private struct ConversationContent: View {
|
||||
@ObservedObject var session: VoiceSession
|
||||
@ObservedObject private var transcripts: TranscriptHandler
|
||||
@ObservedObject private var conversation: ConversationStateModel
|
||||
let displayName: String
|
||||
@State private var composerText = ""
|
||||
@State private var sendError: String?
|
||||
@State private var isSending = false
|
||||
@@ -173,9 +182,10 @@ private struct ConversationContent: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
init(settings: SettingsStore, session: VoiceSession) {
|
||||
init(settings: SettingsStore, session: VoiceSession, displayName: String) {
|
||||
self.settings = settings
|
||||
self.session = session
|
||||
self.displayName = displayName
|
||||
transcripts = session.transcriptHandler
|
||||
conversation = session.stateModel
|
||||
}
|
||||
@@ -217,26 +227,66 @@ private struct ConversationContent: View {
|
||||
|
||||
private var welcome: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: Nearlight.s24) {
|
||||
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, Nearlight.s32)
|
||||
VStack(alignment: .leading, spacing: Nearlight.s12) {
|
||||
Text("A moment with Hope.")
|
||||
.font(NearlightFont.welcome(typeSize))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text("Speak or type to your own assistant.")
|
||||
.font(NearlightFont.support(typeSize))
|
||||
VStack(spacing: Nearlight.s16) {
|
||||
VStack(spacing: Nearlight.s8) {
|
||||
Text("A little room for you,")
|
||||
.font(.largeTitle.weight(.regular))
|
||||
Text(greetingName + ".")
|
||||
.font(.system(.largeTitle, design: .serif).italic())
|
||||
.foregroundStyle(Nearlight.rosewood)
|
||||
Text("No perfect words needed. Start anywhere.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
}
|
||||
statusLine
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, Nearlight.s24)
|
||||
|
||||
LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode))
|
||||
.padding(.top, Nearlight.s8)
|
||||
Text(statusTitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Nearlight.rosewood)
|
||||
.accessibilityIdentifier("talk.status")
|
||||
|
||||
if !typeSize.isAccessibilitySize {
|
||||
VStack(spacing: Nearlight.s8) {
|
||||
HStack(spacing: Nearlight.s8) {
|
||||
prompt("Talk through my day", draft: "Help me talk through my day.")
|
||||
prompt("Explore an idea", draft: "Help me explore an idea.")
|
||||
}
|
||||
prompt("Make a plan", draft: "Help me make a plan.")
|
||||
}
|
||||
.padding(.top, Nearlight.s8)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.padding(.bottom, Nearlight.s24)
|
||||
}
|
||||
}
|
||||
|
||||
private var greetingName: String {
|
||||
displayName.split(separator: " ").first.map(String.init) ?? displayName
|
||||
}
|
||||
|
||||
private func prompt(_ title: String, draft: String) -> some View {
|
||||
Button {
|
||||
// Prepare an editable draft, never submit a prompt or enable capture implicitly.
|
||||
composerText = draft
|
||||
settings.textMode = true
|
||||
composerFocused = true
|
||||
} label: {
|
||||
Text(title)
|
||||
.font(.footnote)
|
||||
.padding(.horizontal, Nearlight.s16)
|
||||
.frame(minHeight: 44)
|
||||
.background(Nearlight.selectedWash, in: Capsule())
|
||||
.foregroundStyle(Nearlight.rosewood)
|
||||
}
|
||||
.accessibilityHint("Prepares a message you can edit. Connect before sending.")
|
||||
}
|
||||
|
||||
/// Status text 24 below the supporting copy on welcome.
|
||||
private var statusLine: some View {
|
||||
Text(statusTitle)
|
||||
@@ -369,21 +419,17 @@ private struct ConversationContent: View {
|
||||
else { HStack(spacing: Nearlight.s12) { muteButton; micMeter } }
|
||||
endButton
|
||||
} else {
|
||||
if settings.textMode { draftPreview }
|
||||
startControls
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.padding(.top, Nearlight.s12)
|
||||
.padding(.bottom, Nearlight.s12)
|
||||
.background(
|
||||
// Dock: Surface, 16-point top corners, decorative top separator.
|
||||
VStack(spacing: 0) {
|
||||
Rectangle().fill(Nearlight.separator).frame(height: 1 / UIScreen.main.scale)
|
||||
Nearlight.surface
|
||||
}
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
.clipShape(UnevenRoundedRectangle(topLeadingRadius: Nearlight.radiusDock, topTrailingRadius: Nearlight.radiusDock))
|
||||
)
|
||||
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
|
||||
.padding(.horizontal, Nearlight.s16)
|
||||
.padding(.bottom, Nearlight.s8)
|
||||
|
||||
}
|
||||
|
||||
@ViewBuilder private var modeSelector: some View {
|
||||
@@ -431,6 +477,16 @@ private struct ConversationContent: View {
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
|
||||
private var draftPreview: some View {
|
||||
TextField("What’s on your mind?", text: $composerText, axis: .vertical)
|
||||
.lineLimit(1...3)
|
||||
.font(.body)
|
||||
.padding(Nearlight.s12)
|
||||
.background(Nearlight.selectedWash, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
.focused($composerFocused)
|
||||
.accessibilityLabel("Message Hope")
|
||||
}
|
||||
|
||||
private var startControls: some View {
|
||||
VStack(spacing: Nearlight.s8) {
|
||||
Text(settings.textMode ? "Your microphone stays off." : "Your microphone starts after you connect.")
|
||||
@@ -520,7 +576,7 @@ private struct ConversationContent: View {
|
||||
private var typeControls: some View {
|
||||
VStack(spacing: Nearlight.s12) {
|
||||
HStack(alignment: .bottom, spacing: Nearlight.s8) {
|
||||
TextField("Message Hope", text: $composerText, axis: .vertical)
|
||||
TextField("What’s on your mind?", text: $composerText, axis: .vertical)
|
||||
.lineLimit(1...5)
|
||||
.padding(Nearlight.s12)
|
||||
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
|
||||
|
||||
@@ -18,8 +18,8 @@ struct LightwellView: View {
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
|
||||
private var side: CGFloat {
|
||||
if compact { return typeSize.isAccessibilitySize ? 88 : 136 }
|
||||
return typeSize.isAccessibilitySize ? 160 : 220
|
||||
if compact { return typeSize.isAccessibilitySize ? 80 : 112 }
|
||||
return typeSize.isAccessibilitySize ? 136 : 184
|
||||
}
|
||||
|
||||
private var animated: Bool {
|
||||
@@ -41,7 +41,7 @@ struct LightwellView: View {
|
||||
}
|
||||
}
|
||||
.frame(width: side, height: side)
|
||||
.background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
|
||||
.background(Nearlight.personaSurface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Nearlight.radiusDock)
|
||||
.strokeBorder(contrast == .increased ? Nearlight.quiet : Nearlight.separator, lineWidth: 1)
|
||||
@@ -52,7 +52,7 @@ struct LightwellView: View {
|
||||
private var tint: Color {
|
||||
switch phase {
|
||||
case .failed: return Nearlight.alert
|
||||
case .idle, .tearingDown, .readyNoInput: return Nearlight.quiet
|
||||
case .tearingDown: return Nearlight.quiet
|
||||
default: return Nearlight.rosewood
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,24 @@ struct LoginView: View {
|
||||
@State private var password = ""
|
||||
@State private var server = ""
|
||||
@State private var serverError: String?
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Text("Sign in to Hope").font(.title2.bold())
|
||||
Text("Use the account provided by your server administrator. Hermes is managed by your server.")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: Nearlight.s12) {
|
||||
Text("Sign in to Hope")
|
||||
.font(NearlightFont.welcome(typeSize))
|
||||
.foregroundStyle(Nearlight.ink)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
Text("Use the account provided by your server administrator. Hermes is managed by your server.")
|
||||
.font(.body)
|
||||
.foregroundStyle(Nearlight.quiet)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, Nearlight.s16)
|
||||
.listRowBackground(Nearlight.ground)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
Section("Account") {
|
||||
TextField("Username", text: $username).textContentType(.username)
|
||||
@@ -24,10 +35,20 @@ struct LoginView: View {
|
||||
password = ""
|
||||
Task { await auth.login(username: username, password: suppliedPassword) }
|
||||
}
|
||||
.font(NearlightFont.control(typeSize))
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Nearlight.rosewood)
|
||||
.foregroundStyle(Nearlight.onRosewood)
|
||||
.frame(minHeight: 44)
|
||||
.padding(.vertical, Nearlight.s4)
|
||||
.disabled(auth.isBusy || username.isEmpty || password.isEmpty)
|
||||
if auth.isBusy { ProgressView("Signing in…") }
|
||||
if let error = auth.error { Text(error).foregroundStyle(.red) }
|
||||
if auth.isBusy { ProgressView("Signing in…").tint(Nearlight.rosewood) }
|
||||
if let error = auth.error {
|
||||
Text(error).foregroundStyle(Nearlight.alert)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Nearlight.surface)
|
||||
Section("Server") {
|
||||
TextField("HTTPS server address", text: $server).keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
@@ -37,17 +58,28 @@ struct LoginView: View {
|
||||
case .failure(let error): serverError = error.localizedDescription
|
||||
}
|
||||
}.disabled(auth.isBusy)
|
||||
if let serverError { Text(serverError).foregroundStyle(.red) }
|
||||
if let serverError {
|
||||
Text(serverError).foregroundStyle(Nearlight.alert)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Text("Connections use HTTPS and secure WebSockets. A private server certificate must be trusted by this iPhone for conversations.")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
.font(.footnote).foregroundStyle(Nearlight.quiet)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if auth.config.baseURL.host == TLSPinning.host, auth.config.baseURL.port == 9178 {
|
||||
Link("Install Hope server certificate in Safari", destination: auth.config.baseURL.appendingPathComponent("hope-server.mobileconfig"))
|
||||
Text("Install Hope LAN Server Trust in Settings → General → VPN & Device Management. Then enable 192.168.86.2 in Settings → General → About → Certificate Trust Settings. Only trust this certificate for your own server.")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
.font(.footnote).foregroundStyle(Nearlight.quiet)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Nearlight.surface)
|
||||
}
|
||||
.foregroundStyle(Nearlight.ink)
|
||||
.tint(Nearlight.rosewood)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Nearlight.ground)
|
||||
.navigationTitle("Hope")
|
||||
.toolbarBackground(Nearlight.ground, for: .navigationBar)
|
||||
.onAppear { server = settings.serverURL }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user