diff --git a/companion/src/routes.ts b/companion/src/routes.ts index 7ca9dbb9b..678ca7ae3 100644 --- a/companion/src/routes.ts +++ b/companion/src/routes.ts @@ -74,7 +74,8 @@ const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [ // second, per-device capability check before it reaches the harness. CLOUD_DESKTOP_JOIN_ROUTE, - // rooms + // rooms — making one, and talking in one + { method: "POST", path: /^\/api\/groups$/ }, { method: "POST", path: /^\/api\/groups\/[\w-]+\/messages$/ }, { method: "POST", path: /^\/api\/groups\/[\w-]+\/read$/ }, diff --git a/docs/ios/bubble-tail-reference.svg b/docs/ios/bubble-tail-reference.svg new file mode 100644 index 000000000..990c8e046 --- /dev/null +++ b/docs/ios/bubble-tail-reference.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/App/ChatListView.swift b/ios/App/ChatListView.swift index 7848e35b5..5a94bc740 100644 --- a/ios/App/ChatListView.swift +++ b/ios/App/ChatListView.swift @@ -1,9 +1,11 @@ // The roster. // -// Styled after the desktop's messaging-app feel rather than a settings -// list: big mascot faces, the bot's role as a chip beside its name, a -// preview line, and no dividers. Anything waiting on you is pulled to the -// top, because that is the one thing a phone is better at than the laptop. +// Messages-shaped: a glass header, your groups across the top, every bot +// below with the unread dot in the bot's own colour at the left edge, and a +// glass bar floating at the bottom. The bar's pill is Updates — only the +// bots that need you, are working, or have something you have not read — +// beside round search and new-bot buttons. Everything scrolls under the +// glass, which is the whole point of the glass. import SwiftUI import CompanionCore @@ -16,37 +18,33 @@ struct ChatListView: View { @State private var path = NavigationPath() @State private var searchHits: [SearchHit] = [] @State private var searching = false + @State private var searchOpen = false + @State private var showingUpdates = false + @State private var showingNewGroup = false + @FocusState private var searchFocused: Bool + + /// Room for the floating bar, so the last row can scroll clear of it. + private static let barClearance: CGFloat = 96 var body: some View { NavigationStack(path: $path) { - // A hand-built header rather than the navigation bar. Two - // reasons: `.searchable` anchors its field to the *bottom* of the - // screen on iOS 26, which is not where a roster's search belongs, - // and an empty-titled nav bar reserves a surprising amount of - // room above the first row. Drawing it here makes the top of the - // list the top of the screen on every iOS. + GeometryReader { geo in VStack(spacing: 0) { header StatusBanner() ScrollView { - LazyVStack(spacing: 0) { + LazyVStack(alignment: .leading, spacing: 0) { if query.isEmpty { - ForEach(session.state.pendingApprovals, id: \.message.id) { pending in - if let chat = chat(forThread: pending.threadId) { - NavigationLink(value: chat) { - WaitingRow(chat: chat, card: pending.message.card) - } - .buttonStyle(.plain) - } - } + groupsStrip + sectionLabel("Bots") + .padding(.top, 18) + .padding(.bottom, 4) } if !query.isEmpty, !searchHits.isEmpty { HStack { - Text("Messages") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Color.secondary) + sectionLabel("Messages") Spacer() if searching { ProgressView().controlSize(.small) } } @@ -62,22 +60,29 @@ struct ChatListView: View { SearchHitRow(hit: hit) } .buttonStyle(.plain) + .padding(.horizontal, 16) } + sectionLabel("Chats") + .padding(.top, 14) + .padding(.bottom, 4) } - ForEach(chats) { summary in + let rows = chats + ForEach(Array(rows.enumerated()), id: \.element.id) { index, summary in NavigationLink(value: summary.chat) { ChatRow( chat: summary.chat, preview: summary.preview, - at: summary.lastActivity + at: summary.lastActivity, + state: MausState.forChat(summary.chat, in: session.state), + waiting: waitingChats.contains(summary.chat.id), + last: index == rows.count - 1 ) } .buttonStyle(.plain) } } - .padding(.horizontal, 16) - .padding(.bottom, 24) + .padding(.bottom, Self.barClearance) } .refreshable { await session.refresh() } .overlay { @@ -96,8 +101,40 @@ struct ChatListView: View { } // top-aligned: the roster fills downward from the header .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .overlay(alignment: .bottom) { bottomBar } + // a bot that stopped for you grows out of the island + .overlay(alignment: .top) { + NeedsYouIsland( + update: session.state.updates.first { $0.kind == .needsYou }, + hasIsland: IslandGeometry.hasIsland(topInset: geo.safeAreaInsets.top) + ) { chat in path.append(chat) } + } + } .toolbar(.hidden, for: .navigationBar) .navigationDestination(for: Chat.self) { ChatView(chat: $0) } +#if DEBUG + // `-store-preview -open-first`: land on the first chat, for the + // screenshot harness and for looking at the chat screen without + // a pairing. + .task { + if ProcessInfo.processInfo.arguments.contains("-open-first"), + path.isEmpty, let first = chats.first { + path.append(first.chat) + } + } +#endif + .sheet(isPresented: $showingUpdates) { + UpdatesSheet { chat in + showingUpdates = false + path.append(chat) + } + } + .sheet(isPresented: $showingNewGroup) { + NewGroupSheet { room in + showingNewGroup = false + path.append(Chat.room(room)) + } + } .task(id: query) { let expected = query guard expected.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 else { @@ -114,60 +151,157 @@ struct ChatListView: View { } } - /// Who you are, and how to find a chat — both at the top, always. + // MARK: - Header + + /// You (the computer you are paired with) on the left, settings on the + /// right, and where you are in between. Glass tiles, like the system's. private var header: some View { - HStack(spacing: 12) { + HStack(alignment: .center) { NavigationLink { SettingsView() } label: { - ProfileAvatar(name: session.connection?.name ?? "You") + ProfileAvatar(name: session.connection?.name ?? "You", size: 30) + .frame(width: 44, height: 44) } .buttonStyle(.plain) + .glassCapsule() + .accessibilityLabel("Settings") - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .font(.system(size: 15, weight: .semibold)) + Spacer(minLength: 8) + + VStack(spacing: 2) { + Text("Chats") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color.primary) + Text(headerSubtitle) + .font(.system(size: 13)) .foregroundStyle(Color.secondary) + .lineLimit(1) + } + + Spacer(minLength: 8) + + NavigationLink { SettingsView() } label: { + Image(systemName: "gearshape") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(Color.primary) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .glassCapsule() + .accessibilityLabel("Settings") + } + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 12) + } - TextField("Search chats", text: $query) - .font(.system(size: 16)) - .submitLabel(.search) - .autocorrectionDisabled() + private var headerSubtitle: String { + let name = session.connection?.name ?? "Not paired" + switch session.status { + case .live: return "\(name) · connected" + case .connecting: return "\(name) · connecting…" + case .offline: return "\(name) · offline" + case .unauthorized: return "\(name) · unpaired" + case .unpaired: return "Not paired" + } + } - if !query.isEmpty { + // MARK: - Groups + + private var groupsStrip: some View { + VStack(alignment: .leading, spacing: 10) { + sectionLabel("Groups") + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(session.state.rooms) { room in + NavigationLink(value: Chat.room(room)) { + GroupTile(room: room) + } + .buttonStyle(.plain) + } Button { - query = "" + showingNewGroup = true } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(Color.secondary) + GroupTile(room: nil) } .buttonStyle(.plain) + .accessibilityLabel("New group") } + .padding(.horizontal, 16) } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(Capsule().fill(Color.secondary.opacity(0.16))) - - // Same place the desktop puts it, top-right of the roster. - Button { - Task { - if let bot = await session.createBot() { path.append(Chat.bot(bot)) } - } - } label: { - Image(systemName: "plus") - .font(.system(size: 17, weight: .semibold)) + } + .padding(.top, 2) + } + + // MARK: - Bottom bar + + private var bottomBar: some View { + GlassGroup(spacing: 8) { + HStack(spacing: 8) { + if searchOpen { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.secondary) + TextField("Search chats", text: $query) + .font(.system(size: 17)) + .submitLabel(.search) + .autocorrectionDisabled() + .focused($searchFocused) + if !query.isEmpty { + Button { + query = "" + } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(Color.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .frame(height: 52) + .glassCapsule() + + Button("Cancel") { + query = "" + searchOpen = false + searchFocused = false + } + .font(.system(size: 17)) .foregroundStyle(Color.primary) - .frame(width: 34, height: 34) + .padding(.horizontal, 16) + .frame(height: 52) + .glassCapsule() + } else { + UpdatesPill(updates: session.state.updates) { showingUpdates = true } + .frame(height: 52) + + GlassButton(systemImage: "magnifyingglass", size: 48, weight: .semibold) { + searchOpen = true + searchFocused = true + } + .accessibilityLabel("Search") + + GlassButton(systemImage: "square.and.pencil", size: 48, weight: .medium) { + Task { + if let bot = await session.createBot() { path.append(Chat.bot(bot)) } + } + } + .accessibilityLabel("New bot") + } } - .buttonStyle(.plain) - .accessibilityLabel("New bot") } .padding(.horizontal, 16) - .padding(.top, 6) - .padding(.bottom, 10) + .padding(.bottom, 8) + .animation(.snappy(duration: 0.25), value: searchOpen) } + // MARK: - Data + private var chats: [ChatSummary] { let all = session.state.chatSummaries - guard !query.isEmpty else { return all } + guard !query.isEmpty else { + // rooms live in the strip; the list is bots + return all.filter { if case .bot = $0.chat { return true } else { return false } } + } return all.filter { $0.chat.name.localizedCaseInsensitiveContains(query) || $0.chat.subtitle.localizedCaseInsensitiveContains(query) @@ -175,136 +309,243 @@ struct ChatListView: View { } } - private func chat(forThread threadId: String) -> Chat? { - if let bot = session.state.bot(forThread: threadId) { return .bot(bot) } - if let room = session.state.room(forThread: threadId) { return .room(room) } - return nil + private var waitingChats: Set { + Set(session.state.pendingApprovals.compactMap { session.state.chat(forThread: $0.threadId)?.id }) + } + + private func sectionLabel(_ text: String) -> some View { + Text(text.uppercased()) + .font(.system(size: 13, weight: .semibold)) + .tracking(0.4) + .foregroundStyle(Color.secondary) + .padding(.horizontal, 20) } } -struct SearchHitRow: View { - let hit: SearchHit +// MARK: - Rows and tiles - var body: some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: hit.role == .user ? "person.fill" : "bubble.left.fill") - .foregroundStyle(Color.secondary) - .frame(width: 26, height: 26) - .background(Circle().fill(Color.secondary.opacity(0.13))) +/// A room as a round tile: the first two members' mascots stacked, its name +/// beneath. `nil` is the "make one" tile. +struct GroupTile: View { + let room: Room? + @EnvironmentObject private var session: Session - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(hit.name).font(.system(size: 15, weight: .semibold)) - if let task = hit.task, !task.isEmpty { - Text(task).font(.system(size: 12)).foregroundStyle(Color.secondary) + var body: some View { + VStack(spacing: 7) { + ZStack { + if let room { + Circle().fill(Color.secondary.opacity(0.14)) + let colors = memberColors(room) + if let first = colors.first { + MausAvatar(color: first, size: 34, state: .happy, animated: false) + .offset(x: -9, y: -6) } - Spacer() - Text(RelativeStamp.list(hit.at)) - .font(.system(size: 12)) + if colors.count > 1 { + MausAvatar(color: colors[1], size: 30, state: .happy, animated: false) + .padding(2) + .background(Circle().fill(Color(uiColor: .systemBackground))) + .offset(x: 11, y: 9) + } + if room.unread { + Circle() + .fill(MausPalette.color("blue")) + .frame(width: 10, height: 10) + .overlay(Circle().stroke(Color(uiColor: .systemBackground), lineWidth: 2)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(3) + } + } else { + Circle() + .strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [4, 4])) + .foregroundStyle(Color.secondary.opacity(0.6)) + Image(systemName: "plus") + .font(.system(size: 22, weight: .medium)) .foregroundStyle(Color.secondary) } - Text(hit.snippet) - .font(.system(size: 14)) - .foregroundStyle(Color.secondary) - .lineLimit(2) - .multilineTextAlignment(.leading) } + .frame(width: 64, height: 64) + + Text(room?.name ?? "New group") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(room == nil ? Color.secondary : Color.primary) + .lineLimit(1) } - .padding(.vertical, 10) + .frame(width: 76) .contentShape(Rectangle()) } + + private func memberColors(_ room: Room) -> [String] { + room.memberIds.compactMap { session.state.bot($0)?.color } + } } struct ChatRow: View { let chat: Chat let preview: String let at: Double + var state: MausState = .idle + var waiting = false + var last = false var body: some View { - HStack(alignment: .top, spacing: 14) { - MausAvatar(color: chat.color, size: 52) + HStack(alignment: .top, spacing: 0) { + // the unread dot, in the bot's own colour, at the very edge + ZStack { + if chat.unread && !chat.busy { + Circle() + .fill(MausPalette.color(chat.color)) + .frame(width: 10, height: 10) + } + } + .frame(width: 22) + .frame(maxHeight: .infinity) + + HStack(alignment: .top, spacing: 14) { + MausAvatar(color: chat.color, size: 52, state: state) + .padding(.top, 12) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(chat.name) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color.primary) + .lineLimit(1) + .layoutPriority(1) + + // the bot's job, the way the desktop shows it + if !chat.subtitle.isEmpty { + Text(chat.subtitle) + .font(.system(size: 13)) + .foregroundStyle(Color.secondary) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Capsule().fill(Color.secondary.opacity(0.15))) + } - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 8) { - Text(chat.name) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(Color.primary) - .lineLimit(1) - .layoutPriority(1) + Spacer(minLength: 4) - // the bot's job, the way the desktop shows it - if !chat.subtitle.isEmpty { - Text(chat.subtitle) - .font(.system(size: 13)) + Text(RelativeStamp.list(at)) + .font(.system(size: 15)) .foregroundStyle(Color.secondary) - .lineLimit(1) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Capsule().fill(Color.secondary.opacity(0.15))) + .fixedSize() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.secondary.opacity(0.5)) } - Spacer(minLength: 4) - - Text(RelativeStamp.list(at)) - .font(.system(size: 14)) - .foregroundStyle(Color.secondary) - .fixedSize() - } + HStack(alignment: .top, spacing: 8) { + // one line for every bot, so the rows keep one rhythm + Text(preview.isEmpty ? " " : preview) + .font(.system(size: 15)) + .foregroundStyle(Color.secondary) + .lineLimit(1) - HStack(alignment: .top, spacing: 8) { - Text(preview.isEmpty ? " " : preview) - .font(.system(size: 15)) - .foregroundStyle(Color.secondary) - .lineLimit(1) + Spacer(minLength: 0) - Spacer(minLength: 0) + if chat.busy { + ProgressView().controlSize(.mini).padding(.top, 3) + } + } - if chat.busy { - ProgressView().controlSize(.mini) - } else if chat.unread { - Circle() - .fill(MausPalette.color(chat.color)) - .frame(width: 9, height: 9) - .padding(.top, 5) + if waiting { + Label("Waiting on you", systemImage: "hand.raised.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(Capsule().fill(MausPalette.color(chat.color))) + .padding(.top, 4) } } + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay(alignment: .bottom) { + if !last { Divider() } + } } + .padding(.trailing, 16) } - .padding(.vertical, 14) + .padding(.leading, 6) .contentShape(Rectangle()) } } -/// A bot that stopped and needs a person. The whole reason for the app, so -/// it gets to sit above the roster and look unlike everything else. -struct WaitingRow: View { - let chat: Chat - let card: OptionCard? +/// The floating pill: who is doing what right now, at a glance. +struct UpdatesPill: View { + let updates: [ChatUpdate] + let action: () -> Void var body: some View { - HStack(spacing: 12) { - MausAvatar(color: chat.color, size: 38) + Button(action: action) { + HStack(spacing: 8) { + if !updates.isEmpty { + MascotStack(colors: Array(updates.prefix(3).map(\.chat.color))) + } + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + if let first = updates.first { + switch first.kind { + case .needsYou: + Image(systemName: "hand.raised.fill") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(MausPalette.color(first.chat.color)) + Text("\(first.chat.name) needs you") + case .working: + Text("\(first.chat.name) is working") + case .toReview: + Text("\(first.chat.name) has an update") + } + } else { + Text("All quiet") + } + } + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(updates.isEmpty ? Color.secondary : Color.primary) + .lineLimit(1) - VStack(alignment: .leading, spacing: 3) { - Label("\(chat.name) is waiting on you", systemImage: "hand.raised.fill") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Color.primary) - Text(card?.subtitle ?? "") - .font(.system(size: 14)) + Text(subline) + .font(.system(size: 12)) + .foregroundStyle(Color.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) + Image(systemName: "chevron.up") + .font(.system(size: 12, weight: .bold)) .foregroundStyle(Color.secondary) - .lineLimit(2) - .multilineTextAlignment(.leading) } + .padding(.leading, updates.isEmpty ? 16 : 7) + .padding(.trailing, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .glassCapsule() + .accessibilityLabel("Updates") + } + + private var subline: String { + guard let first = updates.first else { return "Nothing needs you" } + let rest = updates.count - 1 + if rest == 0 { return first.line.isEmpty ? " " : first.line } + return rest == 1 ? "1 more update" : "\(rest) more updates" + } +} + +/// Up to three mascots overlapping, the way a group of faces reads at a glance. +struct MascotStack: View { + let colors: [String] + var size: CGFloat = 28 + var overlap: CGFloat = 12 - Spacer(minLength: 0) + var body: some View { + HStack(spacing: -overlap) { + ForEach(Array(colors.enumerated()), id: \.offset) { _, color in + MausAvatar(color: color, size: size, state: .idle, animated: false) + .padding(2) + .background(Circle().fill(Color(uiColor: .systemBackground))) + } } - .padding(14) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color.accentColor.opacity(0.14)) - ) - .padding(.vertical, 6) - .contentShape(Rectangle()) } } @@ -334,8 +575,41 @@ struct StatusBanner: View { .foregroundStyle(tint) .padding(.horizontal, 12) .padding(.vertical, 6) - .background(.regularMaterial, in: Capsule()) - .padding(.top, 4) + .glassCapsule(interactive: false) + .padding(.bottom, 8) + } +} + +struct SearchHitRow: View { + let hit: SearchHit + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: hit.role == .user ? "person.fill" : "bubble.left.fill") + .foregroundStyle(Color.secondary) + .frame(width: 26, height: 26) + .background(Circle().fill(Color.secondary.opacity(0.13))) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(hit.name).font(.system(size: 15, weight: .semibold)) + if let task = hit.task, !task.isEmpty { + Text(task).font(.system(size: 12)).foregroundStyle(Color.secondary) + } + Spacer() + Text(RelativeStamp.list(hit.at)) + .font(.system(size: 12)) + .foregroundStyle(Color.secondary) + } + Text(hit.snippet) + .font(.system(size: 14)) + .foregroundStyle(Color.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + } + .padding(.vertical, 10) + .contentShape(Rectangle()) } } diff --git a/ios/App/ChatView.swift b/ios/App/ChatView.swift index 33e8a5b65..6a2c163e2 100644 --- a/ios/App/ChatView.swift +++ b/ios/App/ChatView.swift @@ -19,10 +19,19 @@ struct ChatView: View { let chat: Chat @EnvironmentObject private var session: Session @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var draft = "" @State private var showingTasks = false + @State private var showingComputer = false + @State private var showingPlus = false @State private var shareFile: ShareFile? @FocusState private var composerFocused: Bool + /// The opening beat: the island grows with the bot's face in it, then + /// shrinks away as the face settles into the header. `facePhase` is 1 + /// with the face in the island, 0 with it home in the header. + @State private var islandExpanded = false + @State private var islandVisible = false + @State private var facePhase: CGFloat = 0 /// The live bubble's scroll target. A constant because there is at most /// one per chat and it has no message id to borrow. @@ -41,6 +50,12 @@ struct ChatView: View { } } + /// Unread elsewhere — what the back pill's badge counts, like Messages. + private var unreadElsewhere: Int { + let mine = current.unread ? 1 : 0 + return max(0, session.state.unreadCount - mine) + } + var body: some View { // Read the transcript once for this render. Pagination changes the // array as a unit; repeatedly reaching through ObservableObject for @@ -62,7 +77,10 @@ struct ChatView: View { // height exact and the anchor land on the newest message. // A thread holds 50 messages until you ask for more, so // there is nothing here worth being lazy about. - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + // room for the floating face when scrolled to the top + Color.clear.frame(height: 72) + if session.state.hasMore[chat.threadId] == true { Button("Load earlier messages") { // keep the reader where they were: after older @@ -80,17 +98,22 @@ struct ChatView: View { } ForEach(Array(transcript.enumerated()), id: \.element.id) { index, message in - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { // a gap in time is worth marking; a timestamp // on every message is just noise if startsANewStretch(at: index, in: transcript) { Text(RelativeStamp.separator(message.date)) - .font(.system(size: 13)) - .foregroundStyle(Color.secondary) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.secondary.opacity(0.7)) .frame(maxWidth: .infinity) - .padding(.top, 6) + .padding(.top, 10) + .padding(.bottom, 4) } - MessageRow(chat: current, message: message) + MessageRow( + chat: current, + message: message, + endsRun: endsRun(at: index, in: transcript) + ) } .id(message.id) } @@ -101,13 +124,13 @@ struct ChatView: View { // that appends the message, so there is never a beat // where both are on screen. if let live = session.state.streaming[chat.threadId], !live.isEmpty { - StreamingBubble(text: live, reasoning: nil) + StreamingBubble(text: live, reasoning: nil, color: current.color) .id(Self.liveBubbleId) } else if let thinking = session.state.reasoning[chat.threadId], !thinking.isEmpty { // Only while there is no answer yet. Once tokens // of the reply exist, the reasoning is behind us // and showing both is just noise. - StreamingBubble(text: nil, reasoning: thinking) + StreamingBubble(text: nil, reasoning: thinking, color: current.color) .id(Self.liveBubbleId) } } @@ -115,6 +138,52 @@ struct ChatView: View { .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) } + // The header lives in the scroll view's top safe area: the + // transcript starts below it and scrolls under it — that is + // what the glass is for. An inset rather than a content + // margin, because `.defaultScrollAnchor(.bottom)` anchored + // unreliably against a margin and opened chats mid-way. + // The blur is only the top strip — back, computer — the way + // a system bar is; the transcript starts on that line and + // scrolls under the face and name, which float over it. + .safeAreaInset(edge: .top, spacing: 0) { headerBar } + .overlay(alignment: .top) { headerFace } + .overlay(alignment: .top) { + // One face, in one layer, measured from the screen's top + // edge: it sits in the island while that is open and + // glides into its header slot when the island lets go. + let topInset = IslandGeometry.topInset + let hasIsland = IslandGeometry.hasIsland(topInset: topInset) + let islandSide: CGFloat = 220 + // centred in the part of the square the hardware island does not cover + let islandFaceCentre = IslandGeometry.top + IslandGeometry.size.height + (islandSide - IslandGeometry.size.height) / 2 + let headerFaceCentre = topInset + 26 + let faceSize = 60 + 72 * facePhase + let faceCentre = headerFaceCentre + (islandFaceCentre - headerFaceCentre) * facePhase + ZStack(alignment: .top) { + if islandVisible { + IslandShell(expanded: islandExpanded, hasIsland: hasIsland, expandedSize: CGSize(width: islandSide, height: islandSide)) { + Color.clear + } + } + MausAvatar(color: current.color, size: faceSize, state: MausState.forChat(current, in: session.state), comets: islandExpanded) + .offset(y: faceCentre - faceSize / 2) + .allowsHitTesting(false) + } + .frame(maxWidth: .infinity, alignment: .top) + .ignoresSafeArea(edges: .top) + } + .task { + // grow, hold a beat, shrink — the face rides along + guard !reduceMotion else { return } + islandVisible = true + try? await Task.sleep(for: .milliseconds(40)) + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { islandExpanded = true; facePhase = 1 } + try? await Task.sleep(for: .milliseconds(1000)) + withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) { islandExpanded = false; facePhase = 0 } + try? await Task.sleep(for: .milliseconds(600)) + islandVisible = false + } // A conversation grows from the bottom: a transcript shorter // than the screen rests at the bottom, and opening a chat // starts on the newest message rather than the oldest. @@ -151,79 +220,19 @@ struct ChatView: View { composer } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .navigationBarTitleDisplayMode(.inline) + .overlay(alignment: .bottom) { plusSheet } + .toolbar(.hidden, for: .navigationBar) .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { dismiss() } label: { - Image(systemName: "chevron.left") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Color.primary) - .frame(width: 32, height: 32) - .background(Circle().fill(Color.secondary.opacity(0.16))) - } - } - ToolbarItem(placement: .principal) { - HStack(spacing: 8) { - MausAvatar(color: current.color, size: 26) - Text(current.name) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(Color.primary) - } - .padding(.leading, 6) - .padding(.trailing, 14) - .padding(.vertical, 5) - .background(Capsule().fill(Color.secondary.opacity(0.16))) - } - if case let .bot(bot) = current { - // Rooms have no computer of their own — whichever member is - // speaking owns one, and picking for the reader would be a - // guess. Bots only. - ToolbarItem(placement: .topBarTrailing) { - NavigationLink { - ComputerView(bot: bot) - } label: { - Image(systemName: "display") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(Color.primary) - } - .accessibilityLabel("Watch \(bot.name)'s computer") - } - } - ToolbarItem(placement: .topBarTrailing) { - Menu { - if case let .bot(bot) = current { - Button("Tasks", systemImage: "square.stack") { showingTasks = true } - .disabled(bot.busy == true) - } - Button("Share as Markdown", systemImage: "doc.plaintext") { - Task { - if let url = await session.export(threadId: current.threadId, format: "markdown") { - shareFile = ShareFile(url: url) - } - } - } - Button("Share as JSON", systemImage: "curlybraces") { - Task { - if let url = await session.export(threadId: current.threadId, format: "json") { - shareFile = ShareFile(url: url) - } - } - } - } label: { - Image(systemName: "ellipsis.circle") - } - .accessibilityLabel("Conversation actions") - } - if current.busy, case let .bot(bot) = current { - ToolbarItem(placement: .topBarTrailing) { - Button("Stop") { Task { await session.interrupt(bot: bot) } } - } - } + .navigationDestination(isPresented: $showingComputer) { + if case let .bot(bot) = current { ComputerView(bot: bot) } } .task { // opening a chat is what marks it read, exactly as on the desktop if current.unread { await session.markRead(current) } +#if DEBUG + // `-open-plus`: the + sheet up, for the screenshot harness + if ProcessInfo.processInfo.arguments.contains("-open-plus") { showingPlus = true } +#endif } .onChange(of: current.unread) { _, unread in // A message can arrive while this chat is already on screen. The @@ -239,6 +248,237 @@ struct ChatView: View { } } + // MARK: - Header + + /// Back on the left with the rest-of-app unread count, the bot's + /// computer on the right — a blurred strip to the top edge. + private var headerBar: some View { + HStack(alignment: .top) { + Button { dismiss() } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .semibold)) + if unreadElsewhere > 0 { + Text("\(unreadElsewhere)") + .font(.system(size: 13, weight: .semibold)) + .padding(.horizontal, 7) + .frame(minWidth: 22, minHeight: 22) + .background(Capsule().fill(Color.secondary.opacity(0.22))) + } + } + .foregroundStyle(Color.primary) + .padding(.leading, 12) + .padding(.trailing, unreadElsewhere > 0 ? 8 : 12) + .frame(height: 44) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .glassCapsule() + .accessibilityLabel("Back") + + Spacer(minLength: 4) + + if case .bot = current { + GlassButton(systemImage: "display", size: 44, weight: .medium) { + showingComputer = true + } + .accessibilityLabel("Watch \(current.name)'s computer") + } else { + Color.clear.frame(width: 44, height: 44) + } + } + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 8) + .background( + Rectangle() + .fill(.ultraThinMaterial) + .mask( + VStack(spacing: 0) { + Color.black + LinearGradient(colors: [.black, .clear], startPoint: .top, endPoint: .bottom) + .frame(height: 20) + } + ) + .padding(.bottom, -20) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) + ) + } + + /// The bot's face over its name pill, floating over the transcript + /// between the two buttons. + private var headerFace: some View { + VStack(spacing: 6) { + // Always here, following the island's face while that one is + // the source: when the island lets go, this one flies home. + // the face itself is drawn by the island layer above, so it can + // travel; this is its seat + Color.clear.frame(width: 60, height: 60) + Menu { + chatActions + } label: { + HStack(spacing: 6) { + Text(current.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.primary) + .lineLimit(1) + if !current.subtitle.isEmpty { + Text(current.subtitle) + .font(.system(size: 13)) + .foregroundStyle(Color.secondary) + .lineLimit(1) + } + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(Color.secondary) + } + .padding(.leading, 12) + .padding(.trailing, 10) + .frame(height: 32) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .glassCapsule() + } + .padding(.top, -4) + } + + /// Everything the name pill and the composer's + can do. One list, two + /// doors — the pill for "about this chat", the + for "do something". + @ViewBuilder + private var chatActions: some View { + if case let .bot(bot) = current { + Button("New task", systemImage: "plus.square.on.square") { + Task { await session.createTask(for: bot, title: nil) } + } + .disabled(bot.busy == true) + Button("Tasks", systemImage: "square.stack") { showingTasks = true } + Button("Watch computer", systemImage: "display") { showingComputer = true } + } + Button("Share as Markdown", systemImage: "doc.plaintext") { + Task { + if let url = await session.export(threadId: current.threadId, format: "markdown") { + shareFile = ShareFile(url: url) + } + } + } + Button("Share as JSON", systemImage: "curlybraces") { + Task { + if let url = await session.export(threadId: current.threadId, format: "json") { + shareFile = ShareFile(url: url) + } + } + } + if current.busy, case let .bot(bot) = current { + Divider() + Button("Interrupt", systemImage: "stop.fill", role: .destructive) { + Task { await session.interrupt(bot: bot) } + } + } + } + + // MARK: - The + sheet + + /// What the composer's + opens: a glass sheet of the things you can do + /// here, each with a line saying what it does. Rises above the composer; + /// tapping anywhere else, or the × the + became, puts it away. + @ViewBuilder + private var plusSheet: some View { + if showingPlus { + ZStack(alignment: .bottom) { + Color.black.opacity(0.35) + .ignoresSafeArea() + .onTapGesture { withAnimation(.snappy(duration: 0.28)) { showingPlus = false } } + + VStack(spacing: 0) { + ForEach(plusActions) { action in + Button { + withAnimation(.snappy(duration: 0.28)) { showingPlus = false } + action.run() + } label: { + HStack(spacing: 16) { + Image(systemName: action.systemImage) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(action.destructive ? Color.red : Color.primary) + .frame(width: 44, height: 44) + .background(Circle().fill(Color.primary.opacity(0.10))) + VStack(alignment: .leading, spacing: 2) { + Text(action.title) + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(action.destructive ? Color.red : Color.primary) + Text(action.subtitle) + .font(.system(size: 13)) + .foregroundStyle(Color.secondary) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 18) + .frame(height: 64) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(action.disabled) + .opacity(action.disabled ? 0.45 : 1) + } + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .glassSheet(cornerRadius: 30) + .padding(.leading, 12) + .padding(.trailing, 44) + .padding(.bottom, 70) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + .transition(.opacity) + } + } + + private struct PlusAction: Identifiable { + let id: String + let systemImage: String + let title: String + let subtitle: String + var destructive = false + var disabled = false + let run: () -> Void + } + + private var plusActions: [PlusAction] { + var out: [PlusAction] = [] + if case let .bot(bot) = current { + out.append(PlusAction( + id: "task", systemImage: "plus.square.on.square", title: "New task", + subtitle: "Start a fresh thread with \(bot.name)", disabled: bot.busy == true + ) { Task { await session.createTask(for: bot, title: nil) } }) + out.append(PlusAction( + id: "tasks", systemImage: "square.stack", title: "Tasks", + subtitle: "Switch, rename or remove one" + ) { showingTasks = true }) + out.append(PlusAction( + id: "computer", systemImage: "display", title: "Watch computer", + subtitle: "Live view of what \(bot.name) is doing" + ) { showingComputer = true }) + } + out.append(PlusAction( + id: "share", systemImage: "doc.plaintext", title: "Share transcript", + subtitle: "This chat as Markdown" + ) { + Task { + if let url = await session.export(threadId: current.threadId, format: "markdown") { + shareFile = ShareFile(url: url) + } + } + }) + if current.busy, case let .bot(bot) = current { + out.append(PlusAction( + id: "stop", systemImage: "stop.fill", title: "Interrupt", + subtitle: "Stop the current turn", destructive: true + ) { Task { await session.interrupt(bot: bot) } }) + } + return out + } + /// True when this message opens a fresh stretch of conversation — the /// first one, or one that follows a gap of half an hour or more. private func startsANewStretch(at index: Int, in messages: [Message]) -> Bool { @@ -246,6 +486,18 @@ struct ChatView: View { return messages[index].at - messages[index - 1].at > 30 * 60 * 1000 } + /// True when the next message is from someone else (or there is none), + /// which is where a run of bubbles gets its tail — one per run, like + /// every messaging app, rather than one per bubble. + private func endsRun(at index: Int, in messages: [Message]) -> Bool { + guard index + 1 < messages.count else { return true } + let this = messages[index], next = messages[index + 1] + if this.role != next.role { return true } + if this.from?.name != next.from?.name { return true } + // a card or a tool chip between two texts breaks the run visually + return next.kind != .text + } + private var canSend: Bool { !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -257,51 +509,81 @@ struct ChatView: View { Task { await session.send(text, to: current) } } + // MARK: - Composer + + /// A round + and a glass pill with the send button inside it. private var composer: some View { - HStack(spacing: 10) { - TextField("Ask \(current.name)", text: $draft, axis: .vertical) - .lineLimit(1...5) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Capsule().fill(Color.secondary.opacity(0.16))) - .focused($composerFocused) - .submitLabel(.send) - // Return sends, Shift+Return breaks the line — the shape - // every chat app has. `.ignored` hands the keypress back to - // the text field, which is what inserts the newline; there is - // no way to type one otherwise once Return is claimed. - .onKeyPress(.return, phases: .down) { press in - guard !press.modifiers.contains(.shift) else { return .ignored } - submit() - return .handled + GlassGroup(spacing: 10) { + HStack(alignment: .bottom, spacing: 10) { + Button { + composerFocused = false + withAnimation(.snappy(duration: 0.28)) { showingPlus.toggle() } + } label: { + Image(systemName: "plus") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(showingPlus ? Color(uiColor: .systemBackground) : Color.primary) + .rotationEffect(.degrees(showingPlus ? 45 : 0)) + .frame(width: 44, height: 44) + .background(Circle().fill(showingPlus ? Color.primary : Color.clear)) + .contentShape(Circle()) } - // software keyboards have no Shift+Return, so their Return - // key is a send — which is what `.submitLabel(.send)` promises - .onSubmit(submit) + .buttonStyle(.plain) + .glassCapsule() + .accessibilityLabel(showingPlus ? "Close" : "More") - Button { - submit() - } label: { - Image(systemName: "arrow.up") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(Color(uiColor: .systemBackground)) - .frame(width: 36, height: 36) - .background( - Circle().fill(canSend ? Color.primary : Color.secondary.opacity(0.35)) - ) + HStack(alignment: .bottom, spacing: 6) { + TextField("Ask \(current.name)", text: $draft, axis: .vertical) + .lineLimit(1...5) + .font(.system(size: 17)) + .padding(.leading, 16) + .padding(.vertical, 11) + .focused($composerFocused) + .submitLabel(.send) + // Return sends, Shift+Return breaks the line — the shape + // every chat app has. `.ignored` hands the keypress back to + // the text field, which is what inserts the newline; there is + // no way to type one otherwise once Return is claimed. + .onKeyPress(.return, phases: .down) { press in + guard !press.modifiers.contains(.shift) else { return .ignored } + submit() + return .handled + } + // software keyboards have no Shift+Return, so their Return + // key is a send — which is what `.submitLabel(.send)` promises + .onSubmit(submit) + + Button { + submit() + } label: { + Image(systemName: "arrow.up") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(canSend ? Color.white : Color.secondary) + .frame(width: 32, height: 32) + .background( + Circle().fill(canSend ? BubbleColor.mine : Color.secondary.opacity(0.18)) + ) + } + .buttonStyle(.plain) + .disabled(!canSend) + .padding(.trailing, 6) + .padding(.bottom, 6) + .animation(.easeOut(duration: 0.15), value: canSend) + } + .frame(minHeight: 44) + .glassCapsule(interactive: false) } - .disabled(!canSend) - .animation(.easeOut(duration: 0.15), value: canSend) } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(.bar) + .padding(.horizontal, 12) + .padding(.top, 6) + .padding(.bottom, 8) } } struct MessageRow: View { let chat: Chat let message: Message + /// Last bubble of a run from the same side: the one that gets the tail. + var endsRun = true @EnvironmentObject private var session: Session @State private var editingText = "" @State private var showingEdit = false @@ -385,7 +667,7 @@ struct MessageRow: View { private var content: some View { switch message.kind { case .text: - TextBubble(message: message) + TextBubble(message: message, chat: chat, tailed: endsRun) case .options: CardView(chat: chat, message: message) case .activity: @@ -399,7 +681,7 @@ struct MessageRow: View { // When there is nothing to show, show nothing — a placeholder // saying "unsupported" is a worse gap than the gap. if let text = message.text, !text.isEmpty { - TextBubble(message: message) + TextBubble(message: message, chat: chat, tailed: endsRun) } } } @@ -428,17 +710,23 @@ private struct ActivityShareSheet: UIViewControllerRepresentable { struct TextBubble: View { let message: Message + let chat: Chat + var tailed = true var body: some View { let mine = message.role == .user - HStack { - if mine { Spacer(minLength: 44) } + // rooms attribute each line to the member who said it + let speaker = message.from + // No face beside the bubble: the bot's face is in the header, and in + // a room the name line says who spoke. The bubble sits at the edge. + HStack(alignment: .bottom, spacing: 0) { + if mine { Spacer(minLength: 56) } + VStack(alignment: .leading, spacing: 4) { - // rooms attribute each line to the member who said it - if let from = message.from { - Text(from.name) + if let speaker, !mine { + Text(speaker.name) .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(MausPalette.color(from.color)) + .foregroundStyle(MausPalette.color(speaker.color)) } // Bots get markdown, you do not — the same split the desktop // makes. Markdown you did not intend is worse than markdown @@ -446,7 +734,7 @@ struct TextBubble: View { if mine { Text(message.text ?? "") .font(.system(size: 17)) - .foregroundStyle(Color.primary) + .foregroundStyle(BubbleColor.mineText) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } else { @@ -456,12 +744,15 @@ struct TextBubble: View { .fixedSize(horizontal: false, vertical: true) } } - .padding(.horizontal, 16) - .padding(.vertical, 12) + .padding(.horizontal, 15) + .padding(.vertical, 11) .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(Color.secondary.opacity(mine ? 0.24 : 0.13)) + SpeechBubble(tail: tailed ? (mine ? .trailing : .leading) : .none) + .fill(mine ? BubbleColor.mine : BubbleColor.theirs) ) + // leave room for the tail below, so the next row does not sit on it + .padding(.bottom, tailed ? SpeechBubble.tailDrop() : 0) + if !mine { Spacer(minLength: 44) } } } @@ -517,17 +808,27 @@ struct CardView: View { option.caseInsensitiveCompare("Deny") == .orderedSame } + private var tint: Color { MausPalette.color(chat.color) } + var body: some View { if let card = message.card { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { + if card.isPending { + Label("\(chat.name) is waiting on you", systemImage: "hand.raised.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tint) + } Text(card.title) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Color.primary) - Text(card.subtitle) - .font(.system(size: 15)) - .foregroundStyle(Color.secondary) - .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) + if !card.subtitle.isEmpty { + Text(card.subtitle) + .font(.system(size: 15)) + .foregroundStyle(Color.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } if let held = card.held { Label(held, systemImage: "exclamationmark.shield") @@ -536,20 +837,29 @@ struct CardView: View { } if card.isPending { - HStack(spacing: 10) { + HStack(spacing: 8) { ForEach(card.options, id: \.self) { option in - Button(option) { + Button { answering = true Task { await session.answer(threadId: chat.threadId, card: card, choice: option) answering = false } + } label: { + Text(option) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Self.isRefusal(option) ? Color.primary : .white) + .frame(maxWidth: .infinity) + .frame(height: 40) + .background( + Capsule().fill(Self.isRefusal(option) ? Color.secondary.opacity(0.18) : tint) + ) } - .buttonStyle(.borderedProminent) - .tint(Self.isRefusal(option) ? Color.secondary : Color.accentColor) + .buttonStyle(.plain) .disabled(answering) } } + .padding(.top, 2) // The grant key comes from the card. The phone never // derives its own, so it cannot permit something subtly @@ -565,7 +875,9 @@ struct CardView: View { answering = false } } - .font(.system(size: 14)) + .font(.system(size: 12)) + .foregroundStyle(Color.secondary) + .frame(maxWidth: .infinity) .disabled(answering) } } else if let answered = card.answered { @@ -574,15 +886,15 @@ struct CardView: View { .foregroundStyle(Color.secondary) } } - .padding(16) + .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(Color.secondary.opacity(0.13)) + .fill(card.isPending ? tint.opacity(0.12) : Color.secondary.opacity(0.13)) ) .overlay { RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(card.isPending ? Color.accentColor : .clear, lineWidth: 1.5) + .strokeBorder(card.isPending ? tint : .clear, lineWidth: 1.5) } } } @@ -642,9 +954,10 @@ struct ScreenShot: View { struct StreamingBubble: View { let text: String? let reasoning: String? + var color: String = "blue" var body: some View { - HStack { + HStack(alignment: .bottom, spacing: 0) { VStack(alignment: .leading, spacing: 4) { if let reasoning, !reasoning.isEmpty, text?.isEmpty != false { // Quieter and smaller than an answer, because it is not @@ -671,12 +984,10 @@ struct StreamingBubble: View { .foregroundStyle(Color.primary) } } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(Color.secondary.opacity(0.13)) - ) + .padding(.horizontal, 15) + .padding(.vertical, 11) + .background(SpeechBubble(tail: .leading).fill(BubbleColor.theirs)) + .padding(.bottom, SpeechBubble.tailDrop()) Spacer(minLength: 44) } // No `.textSelection` on purpose: selecting text that is still growing diff --git a/ios/App/CompanionApp.swift b/ios/App/CompanionApp.swift index 3a20b907b..ac1bf2021 100644 --- a/ios/App/CompanionApp.swift +++ b/ios/App/CompanionApp.swift @@ -10,19 +10,23 @@ import SwiftUI struct CompanionApp: App { @StateObject private var session = Session() @Environment(\.scenePhase) private var scenePhase + @State private var liveActivities = LiveActivityCoordinator() var body: some Scene { WindowGroup { RootView() .environmentObject(session) - .onAppear { session.connect() } + .onAppear { + session.connect() + liveActivities.attach(to: session) + } .onOpenURL { session.receivePairingURL($0) } .onChange(of: scenePhase) { _, phase in switch phase { case .active: session.connect() Task { await session.refreshNotificationAuthorization() } - case .background: session.disconnect() + case .background: session.linger() case .inactive: break @unknown default: break } diff --git a/ios/App/Glass.swift b/ios/App/Glass.swift new file mode 100644 index 000000000..e1246c419 --- /dev/null +++ b/ios/App/Glass.swift @@ -0,0 +1,80 @@ +// The one material the chrome is made of. +// +// Every floating control — the header tiles, the Updates pill, the round +// buttons, the sheets, the chat's back pill and composer — is the same glass, +// so the app reads as one object rather than a collection of buttons. On +// iOS 26 it is the system's Liquid Glass, which refracts what scrolls +// beneath it; before that, a thin material with a hairline does the same +// job in the same shape, just without the light. +import SwiftUI + +/// Something floating over content: a tile, a pill, a sheet. +struct GlassSurface: ViewModifier { + let shape: S + var interactive: Bool = true + var tint: Color? = nil + + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + let base: Glass = interactive ? .regular.interactive() : .regular + content.glassEffect(tint.map { base.tint($0) } ?? base, in: shape) + } else { + content + .background(.ultraThinMaterial, in: shape) + .background(tint?.opacity(0.18) ?? .clear, in: shape) + .overlay(shape.strokeBorder(Color.primary.opacity(0.10), lineWidth: 0.5)) + } + } +} + +extension View { + /// A capsule of glass — pills and round buttons. + func glassCapsule(interactive: Bool = true, tint: Color? = nil) -> some View { + modifier(GlassSurface(shape: Capsule(), interactive: interactive, tint: tint)) + } + + /// A rounded sheet of glass. + func glassSheet(cornerRadius: CGFloat = 28, tint: Color? = nil) -> some View { + modifier(GlassSurface( + shape: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous), + interactive: false, + tint: tint + )) + } +} + +/// Neighbouring glass merges when it touches, the way it does in the +/// system's own bars. A no-op before iOS 26. +struct GlassGroup: View { + var spacing: CGFloat = 8 + @ViewBuilder let content: () -> Content + + var body: some View { + if #available(iOS 26.0, *) { + GlassEffectContainer(spacing: spacing, content: content) + } else { + content() + } + } +} + +/// A round glass button with one glyph — the shape of every action in the +/// chrome that is not a pill. +struct GlassButton: View { + let systemImage: String + var size: CGFloat = 44 + var weight: Font.Weight = .medium + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: size * 0.42, weight: weight)) + .foregroundStyle(Color.primary) + .frame(width: size, height: size) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .glassCapsule() + } +} diff --git a/ios/App/Island.swift b/ios/App/Island.swift new file mode 100644 index 000000000..a46a2b8fc --- /dev/null +++ b/ios/App/Island.swift @@ -0,0 +1,162 @@ +// The island: a black shape at the top of the screen, sitting where the +// hardware Dynamic Island is, that grows into a square with a bot's face +// alive inside it — and shrinks back into the island when it is done. +// +// Apps cannot draw inside the real island, so this is the same trick X +// uses: a black rounded square whose collapsed state hides behind the +// island (or, on phones without one, disappears into the top edge). The +// mascot engine does the rest — it already morphs every state and +// expression, which is what makes the square worth growing. +import SwiftUI +import CompanionCore + +/// The hardware island's frame, in points from the top of the screen, on the +/// phones that have one. Used to hide the collapsed pill behind it. +enum IslandGeometry { + static let size = CGSize(width: 126, height: 37) + static let top: CGFloat = 11 + + /// True on Dynamic Island phones: their top safe-area inset is 59. + static func hasIsland(topInset: CGFloat) -> Bool { topInset >= 54 } + + /// The window's top safe-area inset — the one number that places + /// anything relative to the screen's top edge. + static var topInset: CGFloat { + UIApplication.shared.connectedScenes + .compactMap { ($0 as? UIWindowScene)?.keyWindow } + .first?.safeAreaInsets.top ?? 0 + } +} + +/// The island shape, collapsed or expanded, with whatever is inside it. +struct IslandShell: View { + let expanded: Bool + let hasIsland: Bool + var expandedSize = CGSize(width: 250, height: 330) + @ViewBuilder let content: () -> Content + + var body: some View { + let size = expanded ? expandedSize : IslandGeometry.size + ZStack(alignment: .top) { + RoundedRectangle(cornerRadius: expanded ? 56 : IslandGeometry.size.height / 2, style: .continuous) + .fill(Color.black) + // a hairline, so the square still reads as a shape on a dark + // screen; invisible against the real island when collapsed + .overlay( + RoundedRectangle(cornerRadius: expanded ? 56 : IslandGeometry.size.height / 2, style: .continuous) + .strokeBorder(Color.white.opacity(expanded ? 0.12 : 0), lineWidth: 1) + ) + if expanded { + content() + .frame(width: expandedSize.width, height: expandedSize.height) + .transition(.opacity.combined(with: .scale(scale: 0.92))) + } + } + .frame(width: size.width, height: size.height) + .clipShape(RoundedRectangle(cornerRadius: expanded ? 56 : IslandGeometry.size.height / 2, style: .continuous)) + // collapsed and no hardware island to hide behind: be gone entirely + .opacity(expanded || hasIsland ? 1 : 0) + .scaleEffect(expanded || hasIsland ? 1 : 0.6, anchor: .top) + .padding(.top, IslandGeometry.top) + .shadow(color: .black.opacity(expanded ? 0.45 : 0), radius: 28, y: 12) + .ignoresSafeArea(edges: .top) + } +} + +/// The roster's island: when a bot stops for you, it grows with that bot's +/// face, the question, and the answers. Tap the face to open the chat. +struct NeedsYouIsland: View { + let update: ChatUpdate? + let hasIsland: Bool + let open: (Chat) -> Void + @EnvironmentObject private var session: Session + @State private var shown: ChatUpdate? + @State private var dismissedCardIds = Set() + @State private var answering = false + + private var expanded: Bool { shown != nil } + + var body: some View { + ZStack(alignment: .top) { + if expanded { + // tap anywhere else: put it away, keep the row's tag + Color.black.opacity(0.001) + .ignoresSafeArea() + .onTapGesture { dismiss() } + } + IslandShell(expanded: expanded, hasIsland: hasIsland) { + if let shown { + VStack(spacing: 10) { + // The hardware island covers the first 37pt of the + // square; the face sits clear of it, centred. + Button { open(shown.chat) } label: { + MausAvatar(color: shown.chat.color, size: 120, state: MausState.forChat(shown.chat, in: session.state), comets: true) + } + .buttonStyle(.plain) + .padding(.top, IslandGeometry.size.height + 14) + + VStack(spacing: 4) { + Label("\(shown.chat.name) needs you", systemImage: "hand.raised.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(MausPalette.color(shown.chat.color)) + Text(shown.line.isEmpty ? (shown.card?.title ?? "") : shown.line) + .font(.system(size: 15)) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .lineLimit(3) + .padding(.horizontal, 20) + } + + if let card = shown.card, card.isPending { + HStack(spacing: 8) { + ForEach(card.options, id: \.self) { option in + Button { + answering = true + Task { + await session.answer(threadId: shown.chat.threadId, card: card, choice: option) + answering = false + dismiss() + } + } label: { + Text(option) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(CardStyle.isRefusal(option) ? .white : .white) + .frame(maxWidth: .infinity) + .frame(height: 40) + .background( + Capsule().fill(CardStyle.isRefusal(option) ? Color.white.opacity(0.16) : MausPalette.color(shown.chat.color)) + ) + } + .buttonStyle(.plain) + .disabled(answering) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 18) + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .top) + .animation(.spring(response: 0.55, dampingFraction: 0.78), value: expanded) + .onChange(of: update?.card?.requestId) { _, _ in reconcile() } + .onAppear { reconcile() } + } + + /// Show a new needs-you the moment it lands; never re-show one the user + /// put away; fold away when it is answered elsewhere. + private func reconcile() { + guard let update, update.kind == .needsYou, let id = update.card?.requestId else { + shown = nil + return + } + if dismissedCardIds.contains(id) { shown = nil; return } + shown = update + } + + private func dismiss() { + if let id = shown?.card?.requestId { dismissedCardIds.insert(id) } + shown = nil + } +} diff --git a/ios/App/LiveActivities.swift b/ios/App/LiveActivities.swift new file mode 100644 index 000000000..6c7f13310 --- /dev/null +++ b/ios/App/LiveActivities.swift @@ -0,0 +1,84 @@ +// Keeping the Dynamic Island in step with the bots. +// +// One Live Activity per bot that is doing something — needs you, or working +// — started, updated and ended from the same `updates` the pill reads. The +// stream is foreground-only and there is no push path yet, so the island is +// exact while the app is alive and goes quiet with it; iOS keeps the last +// state on screen for a while, then the activity is ended on the next +// launch if the bot has moved on. +import ActivityKit +import Combine +import Foundation +import CompanionCore + +@MainActor +final class LiveActivityCoordinator { + private var cancellable: AnyCancellable? + private var lastSent: [String: BotActivityAttributes.ContentState] = [:] + /// When each bot's current kind began, so an update does not reset the clock. + private var since: [String: (kind: String, at: Date)] = [:] + + func attach(to session: Session) { + // Answer from the island: the intent runs in this process. + AnswerApprovalIntent.handler = { [weak session] threadId, requestId, choice, isPermission in + await session?.answer(threadId: threadId, requestId: requestId, choice: choice, isPermission: isPermission) + } + cancellable = session.$state + .debounce(for: .milliseconds(400), scheduler: DispatchQueue.main) + .sink { [weak self] state in self?.sync(state) } + } + + private func sync(_ state: CompanionState) { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + let wanted = state.updates.filter { $0.kind != .toReview } + var wantedIds = Set() + + for update in wanted { + guard case let .bot(bot) = update.chat else { continue } + wantedIds.insert(bot.id) + let face = MausState.forBot(bot, last: state.visibleTranscript(forThread: bot.threadId).last) + let kind = update.kind == .needsYou ? "needsYou" : "working" + if since[bot.id]?.kind != kind { since[bot.id] = (kind, Date()) } + let content = BotActivityAttributes.ContentState( + face: face.rawValue, + kind: update.kind == .needsYou ? "needsYou" : "working", + headline: update.kind == .needsYou ? "\(bot.name) needs you" : "\(bot.name) is working", + line: update.line.isEmpty ? (update.card?.title ?? "") : update.line, + requestId: update.card?.isPending == true ? update.card?.requestId : nil, + options: update.card?.isPending == true ? (update.card?.options ?? []) : [], + isPermission: update.card?.isPermission ?? false, + since: since[bot.id]?.at ?? Date() + ) + if lastSent[bot.id] == content { continue } + defer { lastSent[bot.id] = content } + + // A bot stopping for you is worth an alert: the island pops open + // on its own and the lock screen lights up. Working is not. + let alert: AlertConfiguration? = update.kind == .needsYou + ? AlertConfiguration( + title: LocalizedStringResource(stringLiteral: content.headline), + body: LocalizedStringResource(stringLiteral: content.line), + sound: .default + ) + : nil + if let activity = Activity.activities.first(where: { $0.attributes.botId == bot.id }) { + let newAsk = update.kind == .needsYou && lastSent[bot.id]?.requestId != content.requestId + Task { await activity.update(.init(state: content, staleDate: nil), alertConfiguration: newAsk ? alert : nil) } + } else { + let attributes = BotActivityAttributes(botId: bot.id, threadId: bot.threadId, name: bot.name, color: bot.color) + _ = try? Activity.request(attributes: attributes, content: .init(state: content, staleDate: nil), pushType: nil) + // a fresh activity cannot alert on request; one immediate alerting update does it + if let alert, let activity = Activity.activities.first(where: { $0.attributes.botId == bot.id }) { + Task { await activity.update(.init(state: content, staleDate: nil), alertConfiguration: alert) } + } + } + } + + // bots that went quiet: let the island go + for activity in Activity.activities where !wantedIds.contains(activity.attributes.botId) { + lastSent.removeValue(forKey: activity.attributes.botId) + since.removeValue(forKey: activity.attributes.botId) + Task { await activity.end(nil, dismissalPolicy: .immediate) } + } + } +} diff --git a/ios/App/MascotState.swift b/ios/App/MascotState.swift new file mode 100644 index 000000000..753fd2ff4 --- /dev/null +++ b/ios/App/MascotState.swift @@ -0,0 +1,57 @@ +// Which face a bot wears — the desktop's `stateForBot`, ported. +// +// A pinned expression wins; then what the bot is doing right now; then a +// guess from its role. Same rules, same order, so a bot looks the same on +// the phone as on the laptop. +import Foundation +import CompanionCore + +extension MausState { + /// The desktop's legacy names, kept so an older bot record still resolves. + private static let legacy: [String: MausState] = [ + "deadpan": .idle, "friendly": .happy, "focused": .working, "thinking": .thinking, + "excited": .excited, "sleepy": .drowsy, "surprised": .surprised, "skeptical": .suspicious, + "worried": .scared, "mischievous": .playful, + ] + + /// Resolves any stored value — current, legacy or junk — to a real state. + static func normalize(_ value: String?) -> MausState? { + guard let value, !value.isEmpty else { return nil } + return MausState(rawValue: value) ?? legacy[value] + } + + static func forBot(_ bot: Bot, last: Message?) -> MausState { + if let pinned = normalize(bot.mascotExpression) { return pinned } + + if last?.kind == .activity, last?.tool?.ok == false { return .alerting } + if bot.busy == true { return .working } + if bot.unread { return .notifying } + if last?.kind == .options { return .curious } + + let profile = "\(bot.name) \(bot.title) \(bot.description)".lowercased() + func matches(_ words: [String]) -> Bool { + words.contains { word in + profile.range(of: "\\b\(NSRegularExpression.escapedPattern(for: word))\\b", options: .regularExpression) != nil + } + } + if matches(["code", "coding", "developer", "development", "engineer", "engineering", "build", "debug", "program", "software"]) { return .working } + if matches(["research", "researcher", "search", "investigate", "strategy", "strategist", "study", "learn", "knowledge"]) { return .searching } + if matches(["marketing", "growth", "launch", "campaign", "social", "sales", "outreach", "brand"]) { return .excited } + if matches(["overnight", "night", "background", "async", "queue", "batch", "long-running"]) { return .drowsy } + if matches(["monitor", "monitoring", "incident", "alert", "watch", "status", "uptime"]) { return .radar } + if matches(["review", "reviewer", "audit", "critic", "critique", "quality", "qa", "test", "legal"]) { return .suspicious } + if matches(["security", "secure", "compliance", "risk", "privacy", "finance", "financial"]) { return .scared } + if matches(["design", "designer", "creative", "brainstorm", "art", "illustration", "music", "story"]) { return .playful } + if matches(["support", "help", "success", "onboarding", "coach", "teacher", "guide", "welcome"]) { return .happy } + return .idle + } + + /// The face for a chat as a whole: a bot's own, a room's is "happy" — + /// which is what the desktop draws for room avatars. + static func forChat(_ chat: Chat, in state: CompanionState) -> MausState { + switch chat { + case let .bot(bot): return forBot(bot, last: state.visibleTranscript(forThread: bot.threadId).last) + case .room: return .happy + } + } +} diff --git a/ios/App/MausAvatar.swift b/ios/App/MausAvatar.swift index 32e9f25e9..612074910 100644 --- a/ios/App/MausAvatar.swift +++ b/ios/App/MausAvatar.swift @@ -103,21 +103,39 @@ enum MausSilhouette { /// Its bounding box, cached alongside — `boundingRect` walks the path. private static let parsedBounds: CGRect = parsed.boundingRect - /// The silhouette normalised to fill `rect`, preserving aspect. - /// - /// The desktop maps this through a `fit` transform into a 228.541-unit - /// face box. That is not reproduced: normalising to the actual bounds is - /// equivalent for a shape drawn on its own, and it does not go stale if - /// the artwork's framing changes. + /// The silhouette in the desktop's face box: artwork → `translate(210,80)` + /// → `scale(0.593899)` → `translate(-56.5564,-37.6751)`, exactly the + /// transforms the desktop's SVG applies. Every face coordinate — the eye + /// anchor, the mouth — is expressed in this box, so this has to be the + /// same box or the face lands in the wrong place. + static let inFaceBox: Path = parsed.applying( + CGAffineTransform(translationX: 210, y: 80) + .concatenating(CGAffineTransform(scaleX: 0.593899, y: 0.593899)) + .concatenating(CGAffineTransform(translationX: -56.5564, y: -37.6751)) + ) + + /// Its bounds in the face box, for the gradient. + static let faceBoxBounds: CGRect = inFaceBox.boundingRect + + /// The face box with the desktop's 15-unit margin around it (its viewBox + /// is `-15 -15 258.541 258.541`) — room for the body to bob and sway. + static let margin: CGFloat = 15 + + /// The transform that puts the margined face box into `rect`. + static func fit(_ rect: CGRect) -> CGAffineTransform { + let side = MausFaceData.faceBox + margin * 2 + let k = min(rect.width, rect.height) / side + return CGAffineTransform(translationX: margin, y: margin) + .concatenating(CGAffineTransform(scaleX: k, y: k)) + .concatenating(CGAffineTransform( + translationX: rect.minX + (rect.width - side * k) / 2, + y: rect.minY + (rect.height - side * k) / 2 + )) + } + + /// The silhouette normalised into `rect`, the same framing as the desktop. static func path(in rect: CGRect) -> Path { - let bounds = parsedBounds - guard bounds.width > 0, bounds.height > 0 else { return parsed } - let scale = min(rect.width / bounds.width, rect.height / bounds.height) - return parsed.applying( - CGAffineTransform(translationX: -bounds.midX, y: -bounds.midY) - .concatenating(CGAffineTransform(scaleX: scale, y: scale)) - .concatenating(CGAffineTransform(translationX: rect.midX, y: rect.midY)) - ) + inFaceBox.applying(fit(rect)) } /// The SVG path data, once, into a `Path`. Only `M`, `C` and `Z` appear in @@ -180,41 +198,31 @@ enum MausSilhouette { } } -/// A bot, at whatever size the row needs. +/// A bot, at whatever size the row needs — and alive, exactly the way it is +/// on the desktop. `MausFaceEngine` is a port of the frame loop in +/// `MausMascotios/MausAvatar.tsx`: a state picks a pool of expressions and +/// drifts through them on its cadence, a spring morphs the eyes and mouth +/// between them, it blinks on its own rhythm, and the body bobs, sways, +/// breathes or jitters per state. Same data, same numbers, same face. struct MausAvatar: View { let color: String var size: CGFloat = 52 + var state: MausState = .idle + /// Off draws the state's resting face, still. For lists of many. + var animated: Bool = true + /// Comets orbiting the body — the island's "something is happening". + var comets: Bool = false - var body: some View { - Canvas { context, canvasSize in - let rect = CGRect(origin: .zero, size: canvasSize) - let body = MausSilhouette.path(in: rect) - context.fill(body, with: .linearGradient( - Gradient(stops: MausPalette.gradientStops(color)), - startPoint: CGPoint(x: rect.maxX, y: rect.minY), - endPoint: CGPoint(x: rect.minX, y: rect.maxY) - )) + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var engine = MausFaceEngine() - // Eyes, at the desktop's face anchor expressed as a fraction of - // the box: (93, 101) of 228.541. One neutral expression — the - // desktop cycles 25 of them, and at this size the difference - // between any two is a pixel. - let eyeWidth = canvasSize.width * 0.085 - let eyeHeight = eyeWidth * 1.7 - let gap = eyeWidth * 1.9 - let cx = rect.minX + canvasSize.width * 0.407 - let cy = rect.minY + canvasSize.height * 0.442 - for dx in [-gap / 2, gap / 2] { - let eye = Path( - roundedRect: CGRect( - x: cx + dx - eyeWidth / 2, - y: cy - eyeHeight / 2, - width: eyeWidth, - height: eyeHeight - ), - cornerRadius: eyeWidth / 2 - ) - context.fill(eye, with: .color(.white)) + var body: some View { + let live = animated && !reduceMotion + TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !live)) { timeline in + Canvas { context, canvasSize in + engine.setState(state, now: timeline.date) + if live { engine.step(now: timeline.date) } + engine.draw(in: &context, size: canvasSize, color: color, bodyMotion: live, comets: comets, at: timeline.date) } } .frame(width: size, height: size) @@ -222,6 +230,453 @@ struct MausAvatar: View { } } +/// The resting face for a state, drawn once and still. For places the +/// engine cannot run — a widget, a Live Activity — where the system renders +/// a snapshot and the face can only change between updates. +struct MausFaceStill: View { + let color: String + var state: MausState = .idle + var size: CGFloat = 52 + var comets: Bool = false + /// The clock for the comets' phase; a different date is a different frame. + var at: Date = Date() + + var body: some View { + Canvas { context, canvasSize in + let engine = MausFaceEngine() + engine.setState(state, now: Date(timeIntervalSinceReferenceDate: 0)) + engine.draw(in: &context, size: canvasSize, color: color, bodyMotion: false, comets: comets, at: at) + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +/// The face, frame by frame. One per drawn mascot; holds the morph in +/// progress, the blink, and when the next expression or blink is due. +final class MausFaceEngine { + private(set) var state: MausState = .idle + private var expression = 0 + private var currentRings: [[CGPoint]] = [MausFaceData.ring(0, eye: 0), MausFaceData.ring(0, eye: 1)] + private var targetRings: [[CGPoint]] = [MausFaceData.ring(0, eye: 0), MausFaceData.ring(0, eye: 1)] + private var currentMouth = MausFaceData.mouth(0) + private var targetMouth = MausFaceData.mouth(0) + private var currentGaze = MausFaceData.gaze(0) + private var targetGaze = MausFaceData.gaze(0) + private var morph: CGFloat = 1 + private var velocity: CGFloat = 0 + private var blinkStart: Date? + private var nextExpressionAt: Date? + private var nextBlinkAt: Date? + private var stateStart = Date() + private var last: Date? + private var started = false + + /// The desktop's `lookAround` default: how much of an expression's own + /// look-direction to keep. + private let lookAround: CGFloat = 0.35 + /// Spring stiffness for the morph, the desktop's default. + private let spring: CGFloat = 7 + + func setState(_ new: MausState, now: Date) { + guard !started || new != state else { return } + started = true + state = new + stateStart = now + select(MausFaceData.pools[new]?.first ?? 0) + if last == nil { morph = 1; velocity = 0 } // first frame: rest on it, no morph in + nextExpressionAt = schedule(MausFaceData.expressionCadence[new], from: now) + nextBlinkAt = schedule(MausFaceData.blink[new], from: now) + } + + private func schedule(_ range: (CGFloat, CGFloat)?, from now: Date) -> Date? { + guard let range else { return nil } + let ms = range.0 + CGFloat.random(in: 0...1) * (range.1 - range.0) + return now.addingTimeInterval(TimeInterval(ms / 1000)) + } + + private func select(_ index: Int) { + let i = ((index % MausFaceData.expressionCount) + MausFaceData.expressionCount) % MausFaceData.expressionCount + if i == expression && morph >= 1 { return } + currentRings = displayedRings() + currentMouth = displayedMouth() + currentGaze = displayedGaze() + targetRings = [MausFaceData.ring(i, eye: 0), MausFaceData.ring(i, eye: 1)] + targetMouth = MausFaceData.mouth(i) + targetGaze = MausFaceData.gaze(i) + expression = i + morph = 0 + velocity = 0 + } + + func step(now: Date) { + let dt = CGFloat(min(now.timeIntervalSince(last ?? now), 0.1)) + last = now + // the spring towards morph == 1 + let f = spring + velocity += (-2 * f * velocity - f * f * (morph - 1)) * dt + morph += velocity * dt + if !morph.isFinite { morph = 1; velocity = 0 } + + if let due = nextExpressionAt, now >= due { + let pool = MausFaceData.pools[state] ?? [0] + let alternatives = pool.filter { $0 != expression } + select(alternatives.randomElement() ?? pool[0]) + nextExpressionAt = schedule(MausFaceData.expressionCadence[state], from: now) + } + if let due = nextBlinkAt, now >= due { + blinkStart = now + nextBlinkAt = schedule(MausFaceData.blink[state], from: now) + } + } + + // MARK: - Drawing + + // MARK: - Comets + + /// Comets orbiting the body on tilted rings — the desktop's `trails`. + struct CometSpec { + let count: Int + /// ms for one orbit + let period: CGFloat + /// face units + let radius: CGFloat + /// half-width at the head + let width: CGFloat + /// how much of the orbit one comet covers, radians + let span: CGFloat + /// the body's resting scale while the rings are out, so they clear it + let bodyScale: CGFloat + } + + static let cometTrails: [MausState: CometSpec] = [ + .orbit: CometSpec(count: 6, period: 3000, radius: 105, width: 5, span: 2.5, bodyScale: 0.72), + .radar: CometSpec(count: 4, period: 2400, radius: 106, width: 4.5, span: 2.1, bodyScale: 0.72), + .progress: CometSpec(count: 5, period: 2000, radius: 104, width: 4.8, span: 2.3, bodyScale: 0.74), + .loading: CometSpec(count: 5, period: 2400, radius: 105, width: 5, span: 2.6, bodyScale: 0.72), + .uploading: CometSpec(count: 4, period: 1800, radius: 104, width: 4.8, span: 2.2, bodyScale: 0.74), + ] + + /// One palette per comet: three neighbouring hues along its length. A flat + /// colour reads as wire; a hue that travels reads as something lit. + static let cometColors: [[Color]] = [ + ["#A855F7", "#6366F1", "#38BDF8"], ["#22D3EE", "#34D399", "#A3E635"], ["#FB923C", "#F43F5E", "#D946EF"], + ["#818CF8", "#C084FC", "#F472B6"], ["#FACC15", "#FB923C", "#EC4899"], ["#34D399", "#22D3EE", "#60A5FA"], + ].map { $0.map { Color(hex: $0) } } + + private struct CometSample { var x: CGFloat; var y: CGFloat; var h: CGFloat } + private struct CometPiece { let path: Path; let shading: GraphicsContext.Shading; let front: Bool } + + private static func hash01(_ n: CGFloat) -> CGFloat { + let x = sin(n * 127.1 + 311.7) * 43758.5453 + return x - floor(x) + } + + /// The comets for this frame, each split at the horizon into pieces that + /// go behind the body or in front of it. + private func comets(_ spec: CometSpec, elapsed: CGFloat, centre: CGPoint, strength: CGFloat) -> [CometPiece] { + var out: [CometPiece] = [] + let samples = 22 + let span = min(spec.span, .pi - 0.05) + let near: CGFloat = 1.1 // swell at the near point of the ring — perspective, cheaply + + for i in 0.. Bool { + guard r.count >= 3 else { return false } + var length: CGFloat = 0 + for k in 1.. spec.width * 3.5 + } + for sIndex in 0..= 0 ? 1 : -1 + if nowSide != side { + if keep(run) { runs.append((run, side > 0)) } + run = run.isEmpty ? [] : [run[run.count - 1]] + side = nowSide + } + run.append(CometSample(x: x, y: y, h: h)) + } + if keep(run) { runs.append((run, side > 0)) } + guard let firstRun = runs.first, let lastRun = runs.last else { continue } + + // one gradient across the whole comet, so a hue runs head to tail across a cut + let colors = Self.cometColors[i % Self.cometColors.count] + let shading = GraphicsContext.Shading.linearGradient( + Gradient(stops: [ + .init(color: colors[0], location: 0), + .init(color: colors[1], location: 0.52), + .init(color: colors[2].opacity(0.35), location: 1), + ]), + startPoint: CGPoint(x: firstRun.samples[0].x, y: firstRun.samples[0].y), + endPoint: CGPoint(x: lastRun.samples[lastRun.samples.count - 1].x, y: lastRun.samples[lastRun.samples.count - 1].y) + ) + for (r, piece) in runs.enumerated() { + out.append(CometPiece( + path: Self.cometPath(piece.samples, capHead: r == 0, capTail: r == runs.count - 1), + shading: shading, + front: piece.front + )) + } + } + return out + } + + /// Head→tail samples to one filled, tapered outline with half-round caps + /// on the true ends — a comet is an outline, not a stroke. + private static func cometPath(_ s: [CometSample], capHead: Bool, capTail: Bool) -> Path { + let n = s.count + var path = Path() + guard n >= 2 else { return path } + var nx: [CGFloat] = [], ny: [CGFloat] = [] + for i in 0.. [CGPoint] { + let steps = 6 + let x = s[i].x, y = s[i].y, h = s[i].h + let tx = ny[i], ty = -nx[i] + return (1.. CGAffineTransform { + let centre = MausFaceData.faceBox / 2 + let ground = MausFaceData.faceBox + func wave(_ period: CGFloat, _ phase: CGFloat = 0) -> CGFloat { sin(elapsed / period * .pi * 2 + phase) } + var dx: CGFloat = 0, dy: CGFloat = 0 + var rotation = m.tilt ?? 0 + var scale: CGFloat = 1, sx: CGFloat = 1, sy: CGFloat = 1 + if let (amplitude, period) = m.bob { + let p = wave(period) + dy -= amplitude * p + if let squash = m.squash { + let amount = squash * max(0, -p) + sy = 1 - amount * 0.5 + sx = 1 + amount * 0.5 + } + } + if let (radius, period) = m.circle { + dx += radius * wave(period) + dy += radius * wave(period, .pi / 2) + } + if let (degrees, period) = m.sway { rotation += degrees * wave(period) } + if let (fraction, period) = m.pulse { scale *= 1 + fraction * wave(period) } + if let (amplitude, period) = m.jitter { + dx += amplitude * wave(period) + dy += amplitude * wave(period * 0.63, 1.1) + } + if let (from, duration) = m.enter { + let t = elapsed / duration + scale *= t >= 1 ? 1 : from + (1 - from) * Self.easeOutBack(max(t, 0)) + } + if let settle = m.settle { + let t = min(max(elapsed / 1400, 0), 1) + scale *= 1 + (settle - 1) * Self.easeInOut(t) + } + // SVG applies the list right-to-left: squash, then scale, then rotate, + // then translate. Build it in that order. + var t = CGAffineTransform.identity + if sx != 1 || sy != 1 { + t = t.concatenating(CGAffineTransform(translationX: -centre, y: -ground)) + .concatenating(CGAffineTransform(scaleX: sx, y: sy)) + .concatenating(CGAffineTransform(translationX: centre, y: ground)) + } + if scale != 1 { + t = t.concatenating(CGAffineTransform(translationX: -centre, y: -centre)) + .concatenating(CGAffineTransform(scaleX: scale, y: scale)) + .concatenating(CGAffineTransform(translationX: centre, y: centre)) + } + if rotation != 0 { + t = t.concatenating(CGAffineTransform(translationX: -centre, y: -centre)) + .concatenating(CGAffineTransform(rotationAngle: rotation * .pi / 180)) + .concatenating(CGAffineTransform(translationX: centre, y: centre)) + } + if dx != 0 || dy != 0 { t = t.concatenating(CGAffineTransform(translationX: dx, y: dy)) } + return t + } + + private func blinkScale(now: Date) -> CGFloat { + guard let start = blinkStart else { return 1 } + let t = CGFloat(now.timeIntervalSince(start)) / 0.320 + if t >= 1 { blinkStart = nil; return 1 } + // fast close, slower open + return max(t < 0.42 ? 1 - t / 0.42 : (t - 0.42) / 0.58, 0.04) + } + + private func displayedRings() -> [[CGPoint]] { + let m = min(max(morph, 0), 1) + return currentRings.enumerated().map { eye, ring in + ring.enumerated().map { i, p in + let q = targetRings[eye][i] + return CGPoint(x: p.x + (q.x - p.x) * m, y: p.y + (q.y - p.y) * m) + } + } + } + private func displayedMouth() -> [CGFloat] { + let m = min(max(morph, 0), 1) + return currentMouth.enumerated().map { i, v in v + (targetMouth[i] - v) * m } + } + private func displayedGaze() -> CGPoint { + let m = min(max(morph, 0), 1) + return CGPoint(x: currentGaze.x + (targetGaze.x - currentGaze.x) * m, y: currentGaze.y + (targetGaze.y - currentGaze.y) * m) + } + + private static func centre(_ ring: [CGPoint]) -> CGPoint { + var x: CGFloat = 0, y: CGFloat = 0 + for p in ring { x += p.x; y += p.y } + return CGPoint(x: x / CGFloat(ring.count), y: y / CGFloat(ring.count)) + } + + /// The mouth hangs off the eyes: centred under the pair, tilted with + /// them, pushed clear of whichever eye is tallest. + private static func mouthFrame(_ rings: [[CGPoint]], _ spec: [CGFloat]) -> (x: CGFloat, y: CGFloat, angle: CGFloat) { + let c0 = centre(rings[0]), c1 = centre(rings[1]) + let theta = atan2(c1.y - c0.y, c1.x - c0.x) + var halfHeight: CGFloat = 0 + for ring in rings { + var lo = CGFloat.infinity, hi = -CGFloat.infinity + for p in ring { lo = min(lo, p.y); hi = max(hi, p.y) } + halfHeight = max(halfHeight, (hi - lo) / 2) + } + let drop = halfHeight + spec[2] + return ( + x: (c0.x + c1.x) / 2 - sin(theta) * drop, + y: (c0.y + c1.y) / 2 + cos(theta) * drop, + angle: theta + spec[3] * .pi / 180 + ) + } + + private static func mouthPath(_ frame: (x: CGFloat, y: CGFloat, angle: CGFloat), _ spec: [CGFloat]) -> Path { + let ca = cos(frame.angle), sa = sin(frame.angle) + func at(_ lx: CGFloat, _ ly: CGFloat) -> CGPoint { + CGPoint(x: frame.x + lx * ca - ly * sa, y: frame.y + lx * sa + ly * ca) + } + var path = Path() + path.move(to: at(-spec[0], 0)) + path.addQuadCurve(to: at(spec[0], 0), control: at(0, spec[1])) + return path + } + + private static func easeOutBack(_ t: CGFloat) -> CGFloat { + let c: CGFloat = 1.7, u = t - 1 + return 1 + (c + 1) * u * u * u + c * u * u + } + private static func easeInOut(_ t: CGFloat) -> CGFloat { t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t) } +} + /// The person, not a bot — the roster header and the settings row. A letter /// rather than a mascot, deliberately: the mascots mean "this is a bot", and /// giving the human one too would blur the only distinction the roster makes. diff --git a/ios/App/MausFaceData.swift b/ios/App/MausFaceData.swift new file mode 100644 index 000000000..b710d6907 --- /dev/null +++ b/ios/App/MausFaceData.swift @@ -0,0 +1,550 @@ +// Generated from the Blob Studio export of the mascot (MausMascotios/MausAvatar.tsx; +// the same tables ship in src/components/CursorAvatar.tsx) — do not hand-edit. 25 expressions × 2 eye rings × 48 points, a mouth per expression, +// and per-state pools, cadences, blink rhythm and body motion. Same numbers as +// the desktop, which is the whole point. +import CoreGraphics + +enum MausState: String, CaseIterable { + case sleeping = "sleeping" + case waking = "waking" + case idle = "idle" + case listening = "listening" + case thinking = "thinking" + case searching = "searching" + case working = "working" + case excited = "excited" + case surprised = "surprised" + case suspicious = "suspicious" + case angry = "angry" + case drowsy = "drowsy" + case happy = "happy" + case curious = "curious" + case confused = "confused" + case bored = "bored" + case proud = "proud" + case shy = "shy" + case sad = "sad" + case laughing = "laughing" + case scared = "scared" + case playful = "playful" + case celebrate = "celebrate" + case orbit = "orbit" + case radar = "radar" + case progress = "progress" + case thinkingDots = "thinking-dots" + case spawning = "spawning" + case humming = "humming" + case loading = "loading" + case dictating = "dictating" + case sending = "sending" + case receiving = "receiving" + case uploading = "uploading" + case writing = "writing" + case notifying = "notifying" + case alerting = "alerting" + case bouncing = "bouncing" + case dragging = "dragging" + case poweringDown = "powering-down" +} + +struct MausBodyMotion { + var bob: (CGFloat, CGFloat)? = nil + var sway: (CGFloat, CGFloat)? = nil + var pulse: (CGFloat, CGFloat)? = nil + var circle: (CGFloat, CGFloat)? = nil + var jitter: (CGFloat, CGFloat)? = nil + var tilt: CGFloat? = nil + var squash: CGFloat? = nil + var enter: (CGFloat, CGFloat)? = nil + var settle: CGFloat? = nil +} + +enum MausFaceData { + static let faceBox: CGFloat = 228.541 + static let faceCentre = CGPoint(x: 120, y: 122.5) + static let mouthStroke: CGFloat = 7.5 + static let anchor = (x: CGFloat(87.04), y: CGFloat(98.65), scale: CGFloat(0.84)) + static let gazeTravel = CGPoint(x: 13.2, y: 8.4) + static let expressionCount = 25 + static let pointsPerRing = 48 + /// expression → eye → point → (x, y), flattened; see `ring(_:eye:)`. + static let expressionsFlat: [CGFloat] = [ + 89.2049, 106.51, 91.5549, 106.72, 93.8249, 107.34, 95.9549, 108.36, 97.8149, 109.81, 99.3149, 111.62, 100.525, 113.65, 101.575, 115.76, + 102.605, 117.89, 103.625, 120.02, 104.635, 122.15, 105.635, 124.29, 106.605, 126.44, 107.555, 128.6, 108.475, 130.78, 109.365, 132.96, + 110.215, 135.16, 110.835, 137.44, 110.945, 139.79, 110.485, 142.1, 109.435, 144.21, 107.885, 145.98, 105.945, 147.31, 103.745, 148.15, + 101.405, 148.46, 99.0649, 148.24, 96.8249, 147.52, 94.7749, 146.35, 93.0149, 144.77, 91.6249, 142.87, 90.5349, 140.78, 89.6149, 138.61, + 88.7149, 136.42, 87.7849, 134.25, 86.8449, 132.09, 85.8749, 129.93, 84.8949, 127.79, 83.8949, 125.65, 82.8749, 123.52, 81.7749, 121.43, + 80.7149, 119.32, 79.8749, 117.12, 79.5649, 114.79, 79.9449, 112.46, 80.9949, 110.36, 82.5949, 108.63, 84.6049, 107.42, 86.8549, 106.72, + 135.455, 97.6102, 137.565, 98.1202, 139.545, 99.0102, 141.365, 100.18, 143.045, 101.56, 144.555, 103.12, 145.875, 104.84, 147.045, 106.67, + 148.105, 108.56, 149.115, 110.49, 150.105, 112.42, 151.075, 114.37, 152.005, 116.33, 152.895, 118.31, 153.765, 120.3, 154.585, 122.31, + 155.375, 124.33, 156.115, 126.37, 156.815, 128.43, 157.315, 130.54, 157.475, 132.71, 157.245, 134.86, 156.425, 136.86, 154.795, 138.25, + 152.675, 138.61, 150.555, 138.18, 148.605, 137.22, 146.875, 135.91, 145.375, 134.35, 144.125, 132.58, 143.095, 130.66, 142.245, 128.67, + 141.475, 126.64, 140.715, 124.6, 139.915, 122.58, 139.095, 120.57, 138.235, 118.58, 137.335, 116.6, 136.415, 114.63, 135.455, 112.68, + 134.465, 110.75, 133.435, 108.84, 132.375, 106.94, 131.385, 105.01, 130.705, 102.95, 130.605, 100.79, 131.465, 98.8302, 133.295, 97.7202, + 85.3156, 99.2959, 88.3356, 99.4259, 91.2356, 100.266, 93.8556, 101.776, 96.0356, 103.866, 97.6556, 106.426, 98.6856, 109.266, 99.2956, 112.236, + 99.7356, 115.226, 100.186, 118.226, 100.696, 121.216, 101.236, 124.196, 101.796, 127.176, 102.376, 130.146, 102.986, 133.116, 103.616, 136.076, + 104.286, 139.026, 104.986, 141.976, 105.666, 144.926, 105.896, 147.946, 105.186, 150.876, 103.546, 153.406, 101.206, 155.316, 98.4356, 156.496, + 95.4456, 156.966, 92.4256, 156.756, 89.5156, 155.936, 86.8256, 154.546, 84.4956, 152.616, 82.6856, 150.206, 81.5456, 147.406, 80.8356, 144.466, + 80.1556, 141.506, 79.4956, 138.556, 78.8656, 135.586, 78.2556, 132.626, 77.6756, 129.646, 77.1156, 126.666, 76.5756, 123.686, 76.0656, 120.706, + 75.5756, 117.716, 75.1056, 114.716, 74.7356, 111.716, 74.8756, 108.696, 75.7756, 105.806, 77.3956, 103.266, 79.6456, 101.246, 82.3556, 99.9059, + 146.406, 87.3759, 149.416, 87.6359, 152.246, 88.6959, 154.706, 90.4459, 156.636, 92.7659, 157.906, 95.5159, 158.706, 98.4359, 159.386, 101.386, + 160.056, 104.346, 160.696, 107.306, 161.326, 110.266, 161.916, 113.236, 162.496, 116.216, 163.046, 119.196, 163.566, 122.176, 164.076, 125.166, + 164.566, 128.156, 164.976, 131.156, 164.936, 134.176, 164.156, 137.096, 162.736, 139.766, 160.786, 142.086, 158.406, 143.956, 155.696, 145.276, + 152.746, 145.966, 149.726, 145.906, 146.846, 145.026, 144.366, 143.296, 142.626, 140.836, 141.746, 137.946, 141.256, 134.956, 140.786, 131.966, + 140.286, 128.976, 139.766, 125.986, 139.216, 123.006, 138.646, 120.036, 138.046, 117.066, 137.426, 114.096, 136.776, 111.136, 136.116, 108.186, + 135.426, 105.226, 134.766, 102.276, 134.646, 99.2559, 135.246, 96.2859, 136.516, 93.5459, 138.366, 91.1559, 140.706, 89.2459, 143.436, 87.9459, + 93.4923, 73.1875, 98.0423, 73.9975, 102.282, 75.8375, 105.972, 78.6075, 108.902, 82.1875, 110.872, 86.3575, 111.802, 90.8775, 111.572, 95.4975, + 110.512, 99.9975, 109.262, 104.457, 108.022, 108.917, 106.802, 113.377, 105.592, 117.847, 104.392, 122.317, 103.212, 126.797, 102.042, 131.277, + 100.892, 135.757, 99.7823, 140.257, 98.6923, 144.757, 97.5423, 149.237, 95.6323, 153.438, 92.4523, 156.777, 88.4023, 158.977, 83.9023, 160.037, + 79.2823, 160.037, 74.7623, 159.077, 70.5123, 157.267, 66.7023, 154.647, 63.5423, 151.287, 61.2723, 147.267, 60.1923, 142.777, 60.5923, 138.188, + 61.6623, 133.677, 62.7823, 129.188, 63.9123, 124.697, 65.0723, 120.218, 66.2423, 115.737, 67.4223, 111.257, 68.6223, 106.787, 69.8323, 102.327, + 71.0623, 97.8575, 72.3023, 93.3975, 73.5223, 88.9375, 74.9823, 84.5375, 77.3223, 80.5675, 80.5523, 77.2575, 84.4823, 74.8275, 88.8823, 73.4575, + 162.912, 84.6575, 167.242, 85.6475, 171.022, 87.9675, 173.962, 91.3075, 175.882, 95.3175, 176.772, 99.6775, 176.712, 104.127, 176.172, 108.547, + 175.512, 112.957, 174.762, 117.357, 173.902, 121.727, 172.952, 126.087, 171.892, 130.417, 170.722, 134.718, 169.442, 138.987, 168.042, 143.227, + 166.542, 147.417, 164.922, 151.577, 163.122, 155.657, 160.822, 159.457, 157.862, 162.787, 154.382, 165.567, 150.492, 167.747, 146.312, 169.267, + 141.922, 170.017, 137.482, 169.797, 133.302, 168.297, 130.122, 165.247, 128.832, 161.017, 129.472, 156.637, 131.112, 152.487, 132.812, 148.367, + 134.382, 144.197, 135.832, 139.977, 137.162, 135.727, 138.382, 131.438, 139.492, 127.117, 140.492, 122.777, 141.392, 118.407, 142.192, 114.017, + 142.892, 109.617, 143.502, 105.207, 144.232, 100.807, 145.582, 96.5675, 147.802, 92.7075, 150.772, 89.3975, 154.382, 86.7875, 158.492, 85.1075, + 84.4, 104.306, 87.63, 104.886, 90.72, 106.006, 93.61, 107.576, 96.26, 109.526, 98.65, 111.786, 100.77, 114.306, 102.61, 117.026, + 104.18, 119.926, 105.47, 122.946, 106.48, 126.086, 107.2, 129.296, 107.62, 132.556, 107.76, 135.846, 107.58, 139.126, 107.08, 142.376, + 106.24, 145.556, 105.01, 148.616, 103.39, 151.476, 101.37, 154.066, 98.98, 156.316, 96.22, 158.116, 93.19, 159.376, 89.97, 160.026, + 86.68, 160.046, 83.44, 159.456, 80.35, 158.346, 77.46, 156.776, 74.81, 154.826, 72.43, 152.556, 70.31, 150.046, 68.46, 147.316, + 66.89, 144.426, 65.6, 141.396, 64.6, 138.266, 63.88, 135.056, 63.45, 131.796, 63.32, 128.506, 63.49, 125.226, 63.99, 121.966, + 64.84, 118.796, 66.06, 115.736, 67.68, 112.876, 69.7, 110.286, 72.1, 108.036, 74.85, 106.236, 77.89, 104.976, 81.11, 104.326, + 154.3, 84.5957, 157.99, 84.8357, 161.62, 85.5557, 165.12, 86.7257, 168.45, 88.3357, 171.54, 90.3557, 174.35, 92.7657, 176.81, 95.5157, + 178.88, 98.5757, 180.53, 101.886, 181.73, 105.376, 182.47, 109.006, 182.73, 112.686, 182.51, 116.376, 181.81, 120.006, 180.63, 123.506, + 179.02, 126.836, 176.98, 129.916, 174.56, 132.706, 171.79, 135.156, 168.73, 137.226, 165.43, 138.886, 161.93, 140.086, 158.31, 140.816, + 154.62, 141.056, 150.93, 140.816, 147.31, 140.096, 143.8, 138.926, 140.48, 137.316, 137.38, 135.296, 134.58, 132.886, 132.12, 130.136, + 130.04, 127.076, 128.4, 123.766, 127.19, 120.266, 126.46, 116.646, 126.2, 112.966, 126.42, 109.276, 127.12, 105.646, 128.29, 102.136, + 129.91, 98.8157, 131.95, 95.7357, 134.37, 92.9457, 137.13, 90.4957, 140.19, 88.4257, 143.5, 86.7657, 146.99, 85.5657, 150.62, 84.8357, + 66.8498, 105.11, 69.5198, 105.12, 72.1498, 105.65, 74.7798, 106.16, 77.4098, 106.68, 80.0498, 107.19, 82.6798, 107.71, 85.3098, 108.23, + 87.9398, 108.75, 90.5698, 109.27, 93.1998, 109.8, 95.8298, 110.33, 98.4698, 110.86, 101.1, 111.39, 103.73, 111.92, 106.35, 112.46, + 108.98, 113, 111.61, 113.54, 114.06, 114.59, 115.91, 116.51, 116.85, 119, 116.77, 121.67, 115.76, 124.14, 113.89, 126.04, + 111.41, 127, 108.73, 126.97, 106.11, 126.42, 103.49, 125.86, 100.86, 125.32, 98.2298, 124.79, 95.5998, 124.26, 92.9698, 123.73, + 90.3398, 123.21, 87.6998, 122.69, 85.0698, 122.18, 82.4298, 121.68, 79.7998, 121.17, 77.1698, 120.65, 74.5298, 120.14, 71.8998, 119.61, + 69.2698, 119.08, 66.6398, 118.54, 64.1798, 117.52, 62.3398, 115.6, 61.4198, 113.1, 61.4698, 110.43, 62.4798, 107.96, 64.3698, 106.08, + 133.01, 118.42, 135.32, 118.52, 137.59, 119.01, 139.86, 119.5, 142.13, 119.99, 144.4, 120.48, 146.67, 120.98, 148.94, 121.48, + 151.21, 121.98, 153.48, 122.48, 155.75, 122.99, 158.01, 123.5, 160.28, 124.01, 162.55, 124.53, 164.81, 125.04, 167.08, 125.56, + 169.35, 126.06, 171.53, 126.82, 173.11, 128.49, 173.86, 130.68, 173.94, 133, 173.48, 135.27, 172.49, 137.37, 170.9, 139.04, + 168.74, 139.83, 166.45, 139.54, 164.2, 138.95, 161.93, 138.43, 159.67, 137.91, 157.4, 137.4, 155.13, 136.89, 152.86, 136.39, + 150.59, 135.89, 148.32, 135.4, 146.05, 134.91, 143.78, 134.42, 141.51, 133.93, 139.24, 133.44, 136.97, 132.94, 134.7, 132.43, + 132.43, 131.91, 130.31, 131, 128.67, 129.37, 127.73, 127.26, 127.49, 124.96, 127.94, 122.68, 129.06, 120.66, 130.82, 119.17, + 82.8321, 98.369, 85.9721, 98.499, 89.0021, 99.339, 91.7821, 100.819, 94.1621, 102.869, 96.0421, 105.389, 97.2821, 108.279, 98.0021, 111.349, + 98.5421, 114.449, 99.0921, 117.549, 99.6721, 120.649, 100.282, 123.739, 100.932, 126.819, 101.612, 129.899, 102.322, 132.969, 103.072, 136.029, + 103.862, 139.079, 104.702, 142.109, 105.532, 145.149, 105.902, 148.269, 105.242, 151.329, 103.552, 153.969, 101.082, 155.909, 98.1721, 157.089, + 95.0621, 157.529, 91.9221, 157.299, 88.8821, 156.489, 86.0521, 155.129, 83.5221, 153.259, 81.4421, 150.899, 79.9421, 148.139, 78.9921, 145.139, + 78.1721, 142.099, 77.3921, 139.039, 76.6321, 135.989, 75.9121, 132.919, 75.2221, 129.849, 74.5721, 126.759, 73.9521, 123.679, 73.3621, 120.579, + 72.8021, 117.479, 72.2621, 114.379, 71.8621, 111.249, 72.0121, 108.109, 72.9321, 105.109, 74.5921, 102.439, 76.9221, 100.339, 79.7521, 98.969, + 171.842, 104.209, 174.202, 104.899, 175.882, 106.689, 176.552, 109.059, 176.252, 111.509, 175.132, 113.719, 173.342, 115.419, 171.112, 116.509, + 168.782, 117.359, 166.462, 118.239, 164.132, 119.109, 161.792, 119.949, 159.452, 120.779, 157.102, 121.579, 154.742, 122.369, 152.372, 123.129, + 150.002, 123.869, 147.622, 124.579, 145.232, 125.269, 142.842, 125.939, 140.442, 126.589, 138.032, 127.199, 135.622, 127.789, 133.202, 128.359, + 130.732, 128.469, 128.422, 127.619, 126.662, 125.889, 125.782, 123.589, 125.982, 121.139, 127.252, 119.019, 129.232, 117.539, 131.582, 116.759, + 133.992, 116.169, 136.402, 115.549, 138.802, 114.909, 141.202, 114.249, 143.592, 113.579, 145.972, 112.879, 148.352, 112.159, 150.722, 111.419, + 153.092, 110.659, 155.452, 109.879, 157.802, 109.079, 160.142, 108.249, 162.482, 107.409, 164.812, 106.539, 167.132, 105.649, 169.432, 104.709, + 94.5154, 99.0558, 96.6654, 99.1258, 98.7554, 99.5758, 100.725, 100.426, 102.495, 101.636, 103.995, 103.176, 105.175, 104.966, 106.015, 106.936, + 106.455, 109.036, 106.515, 111.176, 106.255, 113.306, 105.835, 115.416, 105.385, 117.516, 104.995, 119.626, 104.605, 121.736, 104.215, 123.846, + 103.795, 125.956, 103.345, 128.056, 102.685, 130.096, 101.685, 131.996, 100.345, 133.666, 98.7154, 135.066, 96.8554, 136.136, 94.8354, 136.856, + 92.7254, 137.206, 90.5754, 137.156, 88.4754, 136.706, 86.5154, 135.856, 84.7554, 134.636, 83.2654, 133.086, 82.0854, 131.296, 81.2554, 129.316, + 80.8254, 127.216, 80.7954, 125.076, 81.0754, 122.946, 81.4954, 120.846, 81.9454, 118.746, 82.3654, 116.636, 82.7754, 114.526, 83.1754, 112.416, + 83.5654, 110.306, 83.9754, 108.196, 84.5854, 106.146, 85.5554, 104.226, 86.8854, 102.546, 88.5054, 101.146, 90.3754, 100.086, 92.3954, 99.3858, + 149.455, 107.806, 151.435, 108.296, 153.225, 109.256, 154.765, 110.586, 155.995, 112.206, 156.895, 114.036, 157.435, 115.996, 157.625, 118.026, + 157.545, 120.066, 157.285, 122.086, 156.955, 124.096, 156.615, 126.106, 156.255, 128.116, 155.885, 130.116, 155.475, 132.116, 155.025, 134.106, + 154.545, 136.086, 153.905, 138.016, 153.005, 139.856, 151.865, 141.536, 150.465, 143.016, 148.835, 144.236, 147.015, 145.146, 145.045, 145.676, + 143.015, 145.776, 141.015, 145.396, 139.175, 144.536, 137.595, 143.246, 136.385, 141.616, 135.555, 139.766, 135.105, 137.776, 135.015, 135.746, + 135.255, 133.716, 135.705, 131.726, 136.155, 129.736, 136.565, 127.746, 136.965, 125.746, 137.345, 123.736, 137.655, 121.726, 137.965, 119.706, + 138.325, 117.706, 138.835, 115.736, 139.635, 113.856, 140.715, 112.136, 142.075, 110.616, 143.665, 109.346, 145.465, 108.396, 147.425, 107.856, + 79.7261, 99.479, 82.8261, 99.679, 85.7561, 100.719, 88.2761, 102.539, 90.3961, 104.819, 92.4261, 107.189, 94.4561, 109.559, 96.4761, 111.929, + 98.4861, 114.309, 100.496, 116.699, 102.486, 119.099, 104.476, 121.499, 106.456, 123.909, 108.436, 126.319, 110.406, 128.729, 112.376, 131.149, + 114.306, 133.599, 115.856, 136.289, 116.656, 139.299, 116.636, 142.409, 115.806, 145.399, 114.216, 148.069, 111.956, 150.209, 109.176, 151.599, + 106.116, 152.159, 103.026, 151.879, 100.126, 150.759, 97.6761, 148.839, 95.6161, 146.499, 93.6661, 144.069, 91.6961, 141.649, 89.7361, 139.229, + 87.7561, 136.819, 85.7761, 134.419, 83.7761, 132.019, 81.7761, 129.629, 79.7661, 127.239, 77.7561, 124.859, 75.7261, 122.489, 73.6861, 120.129, + 71.6361, 117.789, 69.9161, 115.199, 68.9561, 112.239, 68.9261, 109.139, 69.8461, 106.169, 71.5661, 103.579, 73.9061, 101.529, 76.6761, 100.119, + 150.736, 90.649, 153.526, 91.329, 156.026, 92.749, 158.156, 94.679, 159.856, 96.999, 161.086, 99.599, 161.766, 102.399, 161.766, 105.269, + 161.236, 108.099, 160.586, 110.909, 159.896, 113.699, 159.166, 116.489, 158.396, 119.259, 157.596, 122.029, 156.766, 124.789, 155.886, 127.539, + 154.986, 130.269, 154.026, 132.989, 153.036, 135.689, 151.996, 138.379, 150.906, 141.049, 149.506, 143.559, 147.516, 145.619, 145.006, 147.009, + 142.186, 147.559, 139.346, 147.189, 136.756, 145.939, 134.626, 144.019, 133.026, 141.629, 132.006, 138.939, 131.636, 136.089, 131.986, 133.239, + 132.836, 130.489, 133.866, 127.799, 134.876, 125.099, 135.856, 122.389, 136.796, 119.659, 137.706, 116.929, 138.576, 114.189, 139.416, 111.429, + 140.216, 108.659, 140.986, 105.889, 141.706, 103.099, 142.386, 100.299, 143.006, 97.479, 143.796, 94.719, 145.416, 92.359, 147.886, 90.929, + 103.927, 92.1602, 106.497, 92.8102, 108.707, 94.2602, 110.377, 96.3302, 111.367, 98.7802, 111.667, 101.42, 111.337, 104.05, 110.357, 106.51, + 108.867, 108.72, 107.167, 110.76, 105.447, 112.79, 103.757, 114.84, 102.087, 116.91, 100.437, 119, 98.807, 121.11, 97.217, 123.23, + 95.647, 125.38, 94.097, 127.55, 92.587, 129.73, 91.117, 131.95, 89.667, 134.18, 88.227, 136.42, 86.477, 138.41, 84.167, 139.69, + 81.537, 139.95, 79.027, 139.11, 76.967, 137.45, 75.447, 135.28, 74.457, 132.81, 74.007, 130.19, 74.097, 127.54, 74.747, 124.97, + 75.927, 122.59, 77.347, 120.34, 78.847, 118.14, 80.347, 115.94, 81.867, 113.76, 83.417, 111.6, 84.987, 109.46, 86.597, 107.33, + 88.217, 105.23, 89.877, 103.15, 91.547, 101.08, 93.257, 99.0402, 94.977, 97.0102, 96.747, 95.0302, 98.847, 93.4002, 101.287, 92.3802, + 144.037, 101.82, 146.777, 102.5, 149.207, 103.97, 151.127, 106.04, 152.397, 108.58, 153.207, 111.3, 153.937, 114.05, 154.677, 116.79, + 155.427, 119.53, 156.177, 122.27, 156.947, 125.01, 157.707, 127.75, 158.477, 130.48, 159.257, 133.22, 160.037, 135.95, 160.837, 138.68, + 161.637, 141.41, 162.407, 144.14, 162.787, 146.95, 162.377, 149.75, 161.147, 152.3, 159.247, 154.4, 156.857, 155.92, 154.147, 156.77, + 151.317, 156.87, 148.577, 156.18, 146.137, 154.74, 144.187, 152.69, 142.877, 150.17, 142.017, 147.46, 141.237, 144.73, 140.457, 142, + 139.677, 139.26, 138.897, 136.53, 138.127, 133.79, 137.357, 131.06, 136.597, 128.32, 135.847, 125.58, 135.087, 122.84, 134.337, 120.1, + 133.597, 117.35, 132.857, 114.61, 132.537, 111.8, 132.987, 109, 134.227, 106.45, 136.127, 104.35, 138.507, 102.81, 141.207, 101.94, + 86.4249, 109.021, 89.4149, 109.181, 92.3649, 109.681, 95.2349, 110.511, 97.9949, 111.671, 100.595, 113.151, 102.995, 114.931, 105.145, 117.011, + 107.015, 119.351, 108.555, 121.921, 109.725, 124.671, 110.505, 127.561, 110.845, 130.531, 110.755, 133.521, 110.215, 136.461, 109.245, 139.291, + 107.885, 141.951, 106.155, 144.401, 104.115, 146.581, 101.795, 148.471, 99.2449, 150.031, 96.5149, 151.251, 93.6449, 152.121, 90.7049, 152.641, + 87.7149, 152.821, 84.7249, 152.661, 81.7749, 152.161, 78.9049, 151.331, 76.1449, 150.181, 73.5449, 148.701, 71.1449, 146.911, 68.9949, 144.831, + 67.1249, 142.491, 65.5849, 139.931, 64.4149, 137.181, 63.6349, 134.291, 63.2949, 131.321, 63.3949, 128.331, 63.9249, 125.381, 64.8949, 122.551, + 66.2549, 119.891, 67.9849, 117.451, 70.0249, 115.261, 72.3449, 113.371, 74.8949, 111.811, 77.6249, 110.591, 80.4949, 109.721, 83.4349, 109.201, + 153.955, 101.931, 155.445, 101.971, 156.905, 102.261, 158.295, 102.791, 159.565, 103.561, 160.695, 104.541, 161.645, 105.691, 162.405, 106.971, + 162.965, 108.351, 163.335, 109.801, 163.525, 111.271, 163.555, 112.761, 163.415, 114.251, 163.135, 115.711, 162.705, 117.141, 162.135, 118.521, + 161.435, 119.841, 160.615, 121.081, 159.665, 122.231, 158.605, 123.281, 157.435, 124.201, 156.165, 124.991, 154.815, 125.611, 153.385, 126.031, + 151.905, 126.231, 150.415, 126.191, 148.955, 125.901, 147.565, 125.361, 146.295, 124.591, 145.165, 123.611, 144.215, 122.471, 143.465, 121.181, + 142.895, 119.801, 142.525, 118.361, 142.335, 116.881, 142.305, 115.391, 142.445, 113.901, 142.725, 112.441, 143.155, 111.011, 143.725, 109.631, + 144.425, 108.321, 145.245, 107.071, 146.195, 105.921, 147.255, 104.871, 148.425, 103.951, 149.685, 103.161, 151.045, 102.551, 152.475, 102.131, + 94.1383, 85.2829, 97.3083, 85.4129, 100.338, 86.3129, 103.038, 87.9729, 105.178, 90.3129, 106.608, 93.1329, 107.268, 96.2329, 107.068, 99.4029, + 106.368, 102.493, 105.608, 105.583, 104.848, 108.663, 104.108, 111.753, 103.378, 114.843, 102.658, 117.943, 101.948, 121.043, 101.258, 124.133, + 100.568, 127.243, 99.9083, 130.343, 99.2683, 133.453, 98.5883, 136.563, 97.5483, 139.553, 95.7883, 142.193, 93.4083, 144.283, 90.5583, 145.663, + 87.4483, 146.233, 84.2883, 145.993, 81.2783, 144.983, 78.6183, 143.273, 76.4583, 140.953, 74.9583, 138.163, 74.2283, 135.083, 74.2483, 131.913, + 74.7483, 128.773, 75.4483, 125.673, 76.1483, 122.573, 76.8283, 119.473, 77.5183, 116.373, 78.2283, 113.273, 78.9383, 110.183, 79.6683, 107.083, + 80.4083, 103.993, 81.1583, 100.913, 81.9283, 97.8329, 82.6983, 94.7429, 83.8483, 91.7929, 85.6983, 89.2229, 88.1483, 87.2229, 91.0283, 85.8929, + 152.738, 98.6029, 155.818, 99.0329, 158.648, 100.313, 161.068, 102.263, 162.968, 104.723, 164.238, 107.563, 164.798, 110.623, 164.678, 113.733, + 164.198, 116.813, 163.648, 119.883, 163.078, 122.943, 162.458, 126.003, 161.808, 129.053, 161.128, 132.093, 160.408, 135.123, 159.658, 138.153, + 158.878, 141.173, 158.078, 144.183, 157.248, 147.193, 156.328, 150.173, 155.078, 153.013, 153.248, 155.533, 150.898, 157.563, 148.128, 158.983, + 145.088, 159.643, 141.988, 159.463, 139.068, 158.413, 136.578, 156.553, 134.738, 154.043, 133.698, 151.113, 133.478, 148.013, 133.998, 144.943, + 134.838, 141.943, 135.688, 138.943, 136.508, 135.933, 137.288, 132.923, 138.038, 129.893, 138.758, 126.863, 139.438, 123.823, 140.088, 120.773, + 140.698, 117.713, 141.288, 114.653, 141.838, 111.583, 142.348, 108.503, 143.188, 105.513, 144.748, 102.823, 146.958, 100.633, 149.688, 99.1529, + 81.5454, 85.3004, 85.9254, 86.7204, 89.5754, 89.5404, 92.2754, 93.2804, 94.0154, 97.5604, 94.8754, 102.1, 95.5954, 106.67, 96.4254, 111.22, + 97.3754, 115.76, 98.4254, 120.26, 99.5954, 124.74, 100.875, 129.19, 102.265, 133.6, 103.755, 137.98, 105.365, 142.32, 107.085, 146.62, + 108.935, 150.86, 110.835, 155.08, 112.375, 159.44, 112.835, 164.03, 111.935, 168.55, 109.515, 172.47, 105.755, 175.11, 101.275, 176.16, + 96.6754, 175.8, 92.3054, 174.31, 88.3454, 171.93, 84.9054, 168.83, 82.0854, 165.17, 79.9354, 161.08, 78.0554, 156.85, 76.2754, 152.58, + 74.6054, 148.26, 73.0354, 143.91, 71.5854, 139.51, 70.2454, 135.08, 69.0054, 130.62, 67.8754, 126.13, 66.8654, 121.62, 65.9654, 117.08, + 65.1654, 112.52, 64.5154, 107.94, 64.3954, 103.32, 65.1654, 98.7604, 66.8054, 94.4404, 69.3254, 90.5704, 72.7554, 87.4904, 76.9554, 85.6004, + 143.245, 67.6304, 148.075, 67.8504, 152.695, 69.2604, 156.805, 71.8104, 160.135, 75.3204, 162.415, 79.5904, 163.965, 84.1804, 165.355, 88.8304, + 166.745, 93.4704, 168.135, 98.1204, 169.535, 102.76, 170.915, 107.41, 172.305, 112.06, 173.675, 116.71, 175.045, 121.36, 176.445, 126.01, + 177.825, 130.66, 179.135, 135.33, 179.825, 140.11, 179.225, 144.91, 177.405, 149.39, 174.485, 153.24, 170.695, 156.26, 166.315, 158.3, + 161.575, 159.28, 156.735, 159.11, 152.105, 157.73, 147.985, 155.2, 144.675, 151.68, 142.485, 147.36, 141.035, 142.74, 139.675, 138.08, + 138.305, 133.43, 136.935, 128.78, 135.555, 124.13, 134.185, 119.48, 132.795, 114.83, 131.405, 110.18, 130.015, 105.54, 128.605, 100.89, + 127.205, 96.2504, 125.805, 91.6104, 125.125, 86.8204, 125.695, 82.0204, 127.455, 77.5104, 130.335, 73.6204, 134.105, 70.5904, 138.495, 68.5704, + 84.7108, 91.2126, 88.2408, 91.6126, 91.7008, 92.4126, 95.0608, 93.5926, 98.2508, 95.1526, 101.241, 97.0726, 103.981, 99.3426, 106.411, 101.933, + 108.491, 104.813, 110.181, 107.943, 111.441, 111.263, 112.221, 114.733, 112.511, 118.273, 112.281, 121.813, 111.531, 125.283, 110.271, 128.613, + 108.551, 131.723, 106.391, 134.543, 103.871, 137.043, 101.031, 139.173, 97.9208, 140.903, 94.6108, 142.193, 91.1608, 143.053, 87.6308, 143.473, + 84.0808, 143.483, 80.5508, 143.073, 77.0908, 142.273, 73.7308, 141.093, 70.5408, 139.533, 67.5508, 137.613, 64.8108, 135.343, 62.3808, 132.753, + 60.3008, 129.873, 58.6108, 126.753, 57.3508, 123.423, 56.5708, 119.963, 56.2908, 116.423, 56.5208, 112.873, 57.2608, 109.403, 58.5208, 106.073, + 60.2408, 102.973, 62.4008, 100.143, 64.9208, 97.6426, 67.7608, 95.5126, 70.8708, 93.7926, 74.1808, 92.4926, 77.6308, 91.6426, 81.1608, 91.2126, + 162.291, 102.713, 165.461, 102.753, 168.571, 103.333, 171.511, 104.513, 174.131, 106.283, 176.281, 108.613, 177.851, 111.363, 178.851, 114.373, + 179.331, 117.513, 179.361, 120.683, 179.001, 123.833, 178.281, 126.923, 177.231, 129.913, 175.921, 132.803, 174.371, 135.573, 172.591, 138.203, + 170.621, 140.683, 168.451, 143.003, 166.101, 145.133, 163.591, 147.063, 160.911, 148.773, 158.081, 150.213, 155.131, 151.363, 152.061, 152.173, + 148.921, 152.603, 145.751, 152.563, 142.631, 151.983, 139.691, 150.803, 137.071, 149.023, 134.931, 146.693, 133.361, 143.943, 132.361, 140.933, + 131.881, 137.803, 131.851, 134.633, 132.211, 131.483, 132.921, 128.393, 133.971, 125.393, 135.281, 122.503, 136.841, 119.743, 138.611, 117.113, + 140.591, 114.633, 142.761, 112.313, 145.101, 110.183, 147.621, 108.243, 150.301, 106.543, 153.121, 105.103, 156.081, 103.953, 159.141, 103.133, + 106.247, 119.17, 108.647, 119.53, 110.767, 120.71, 112.347, 122.55, 113.257, 124.81, 113.397, 127.23, 112.637, 129.53, 111.007, 131.32, + 108.817, 132.36, 106.417, 132.81, 104.007, 133.18, 101.597, 133.56, 99.1871, 133.93, 96.7671, 134.29, 94.3571, 134.65, 91.9471, 135, + 89.5271, 135.34, 87.1071, 135.68, 84.6971, 136, 82.2771, 136.31, 79.8471, 136.6, 77.4271, 136.87, 74.9971, 137.11, 72.5671, 137.33, + 70.1371, 137.52, 67.7571, 137.12, 65.7471, 135.76, 64.2671, 133.83, 63.3471, 131.58, 63.0771, 129.16, 63.6671, 126.81, 65.2671, 125.01, + 67.5571, 124.24, 69.9871, 124.04, 72.4171, 123.8, 74.8471, 123.54, 77.2671, 123.27, 79.6871, 122.99, 82.1171, 122.69, 84.5371, 122.38, + 86.9471, 122.06, 89.3671, 121.73, 91.7871, 121.38, 94.1971, 121.03, 96.6071, 120.67, 99.0171, 120.29, 101.427, 119.9, 103.837, 119.51, + 173.647, 104.68, 176.147, 105.53, 178.007, 107.4, 178.947, 109.87, 178.957, 112.52, 178.067, 115.02, 176.307, 116.99, 173.937, 118.17, + 171.357, 118.84, 168.777, 119.49, 166.207, 120.16, 163.627, 120.82, 161.047, 121.47, 158.457, 122.11, 155.877, 122.74, 153.287, 123.37, + 150.697, 123.99, 148.107, 124.6, 145.517, 125.2, 142.917, 125.78, 140.317, 126.35, 137.717, 126.9, 135.107, 127.43, 132.497, 127.94, + 129.847, 127.96, 127.397, 126.98, 125.507, 125.13, 124.407, 122.72, 124.287, 120.07, 125.257, 117.62, 127.147, 115.77, 129.577, 114.71, + 132.187, 114.18, 134.797, 113.64, 137.397, 113.08, 139.997, 112.51, 142.597, 111.93, 145.187, 111.34, 147.787, 110.74, 150.377, 110.13, + 152.967, 109.52, 155.547, 108.89, 158.137, 108.25, 160.717, 107.6, 163.297, 106.95, 165.877, 106.28, 168.447, 105.6, 171.007, 104.87, + 94.474, 84.619, 97.724, 85.199, 100.684, 86.659, 103.114, 88.899, 104.784, 91.749, 105.574, 94.949, 105.424, 98.249, 104.724, 101.489, + 103.914, 104.699, 103.134, 107.919, 102.384, 111.139, 101.674, 114.379, 100.994, 117.619, 100.344, 120.869, 99.734, 124.119, 99.144, 127.389, + 98.624, 130.659, 98.124, 133.929, 97.614, 137.199, 96.764, 140.399, 95.224, 143.319, 93.014, 145.769, 90.244, 147.569, 87.094, 148.579, + 83.794, 148.689, 80.584, 147.909, 77.714, 146.269, 75.354, 143.959, 73.604, 141.159, 72.534, 138.029, 72.194, 134.749, 72.474, 131.449, + 72.984, 128.179, 73.564, 124.909, 74.124, 121.649, 74.724, 118.389, 75.354, 115.139, 76.014, 111.889, 76.714, 108.649, 77.444, 105.419, + 78.204, 102.199, 79.004, 98.979, 79.844, 95.779, 81.004, 92.679, 82.814, 89.919, 85.214, 87.639, 88.044, 85.939, 91.184, 84.899, + 130.604, 117.939, 133.104, 118.059, 135.554, 118.599, 138.014, 119.119, 140.474, 119.639, 142.924, 120.159, 145.384, 120.689, 147.834, 121.219, + 150.284, 121.759, 152.734, 122.299, 155.194, 122.849, 157.634, 123.399, 160.084, 123.959, 162.534, 124.519, 164.974, 125.089, 167.424, 125.669, + 169.864, 126.249, 172.314, 126.799, 174.604, 127.809, 176.274, 129.649, 177.064, 132.019, 176.984, 134.519, 176.054, 136.829, 174.284, 138.579, + 171.904, 139.329, 169.424, 139.069, 166.994, 138.429, 164.554, 137.839, 162.104, 137.269, 159.664, 136.699, 157.214, 136.139, 154.764, 135.589, + 152.314, 135.039, 149.864, 134.509, 147.404, 133.979, 144.954, 133.449, 142.494, 132.929, 140.044, 132.399, 137.584, 131.879, 135.134, 131.349, + 132.674, 130.819, 130.224, 130.279, 127.924, 129.309, 126.194, 127.509, 125.334, 125.169, 125.404, 122.679, 126.394, 120.389, 128.234, 118.709, + 98.5568, 107.163, 100.577, 107.493, 102.487, 108.243, 104.217, 109.343, 105.727, 110.733, 106.957, 112.373, 107.887, 114.203, 108.557, 116.143, + 109.057, 118.133, 109.487, 120.143, 109.897, 122.153, 110.297, 124.163, 110.677, 126.183, 111.027, 128.203, 111.347, 130.233, 111.617, 132.273, + 111.727, 134.313, 111.557, 136.363, 111.067, 138.353, 110.257, 140.233, 109.117, 141.943, 107.677, 143.403, 105.967, 144.523, 104.047, 145.233, + 102.007, 145.483, 99.9768, 145.243, 98.0568, 144.533, 96.3368, 143.423, 94.8768, 141.973, 93.7268, 140.283, 92.8668, 138.423, 92.3368, 136.443, + 92.0168, 134.413, 91.6968, 132.383, 91.3568, 130.363, 90.9868, 128.333, 90.6168, 126.323, 90.2168, 124.303, 89.8068, 122.293, 89.3668, 120.283, + 89.0868, 118.253, 89.1468, 116.203, 89.5568, 114.193, 90.3268, 112.293, 91.4268, 110.573, 92.8568, 109.103, 94.5768, 107.983, 96.5168, 107.323, + 136.417, 99.7329, 138.147, 100.293, 139.617, 101.363, 140.837, 102.713, 141.857, 104.223, 142.697, 105.833, 143.377, 107.523, 143.927, 109.263, + 144.387, 111.033, 144.797, 112.803, 145.187, 114.583, 145.547, 116.373, 145.887, 118.163, 146.207, 119.953, 146.487, 121.753, 146.737, 123.563, + 146.957, 125.373, 147.097, 127.193, 147.107, 129.013, 146.947, 130.823, 146.597, 132.613, 146.007, 134.333, 145.147, 135.933, 143.887, 137.243, + 142.187, 137.843, 140.427, 137.453, 138.957, 136.383, 137.817, 134.973, 136.947, 133.373, 136.287, 131.673, 135.817, 129.913, 135.467, 128.123, + 135.197, 126.323, 134.957, 124.513, 134.697, 122.713, 134.397, 120.913, 134.067, 119.123, 133.717, 117.333, 133.337, 115.543, 132.937, 113.773, + 132.517, 111.993, 132.167, 110.203, 131.947, 108.403, 131.947, 106.573, 132.167, 104.773, 132.617, 103.003, 133.387, 101.353, 134.667, 100.083, + 89.0359, 84.1939, 91.8959, 84.9039, 94.2859, 86.6339, 95.9859, 89.0439, 97.0059, 91.8239, 97.6059, 94.7239, 98.0659, 97.6539, 98.5659, 100.574, + 99.1259, 103.494, 99.7359, 106.394, 100.406, 109.284, 101.116, 112.164, 101.886, 115.024, 102.696, 117.874, 103.556, 120.714, 104.446, 123.544, + 105.386, 126.354, 106.356, 129.154, 107.356, 131.944, 108.116, 134.814, 108.216, 137.764, 107.436, 140.614, 105.686, 142.984, 103.096, 144.374, + 100.156, 144.514, 97.3359, 143.644, 94.8359, 142.054, 92.7659, 139.944, 91.1459, 137.464, 89.9259, 134.764, 88.9259, 131.974, 87.9859, 129.154, + 87.0659, 126.334, 86.1959, 123.504, 85.3659, 120.654, 84.5959, 117.794, 83.8659, 114.924, 83.1759, 112.034, 82.5359, 109.134, 81.9559, 106.234, + 81.4159, 103.314, 80.9259, 100.394, 80.4859, 97.4639, 80.3959, 94.5039, 80.9059, 91.5839, 81.9959, 88.8339, 83.7259, 86.4339, 86.1459, 84.7539, + 158.226, 105.624, 161.156, 106.094, 163.886, 107.264, 166.256, 109.054, 168.096, 111.384, 169.246, 114.124, 169.576, 117.074, 169.106, 120.004, + 167.846, 122.694, 166.036, 125.054, 164.096, 127.314, 162.146, 129.564, 160.186, 131.814, 158.226, 134.054, 156.266, 136.284, 154.286, 138.514, + 152.306, 140.744, 150.326, 142.954, 148.326, 145.174, 146.346, 147.394, 144.346, 149.604, 142.306, 151.774, 139.946, 153.564, 137.146, 154.554, + 134.186, 154.704, 131.276, 154.114, 128.566, 152.894, 126.176, 151.134, 124.246, 148.874, 122.956, 146.194, 122.516, 143.264, 122.996, 140.344, + 124.456, 137.754, 126.426, 135.524, 128.436, 133.324, 130.436, 131.124, 132.426, 128.914, 134.416, 126.694, 136.396, 124.474, 138.366, 122.244, + 140.336, 120.004, 142.296, 117.764, 144.256, 115.524, 146.206, 113.274, 148.146, 111.014, 150.126, 108.784, 152.506, 107.014, 155.276, 105.934, + 97.4616, 101.352, 100.222, 102.002, 102.722, 103.352, 104.722, 105.352, 106.062, 107.852, 106.662, 110.632, 106.442, 113.462, 105.732, 116.212, + 104.932, 118.942, 104.152, 121.682, 103.372, 124.412, 102.602, 127.152, 101.822, 129.892, 101.052, 132.632, 100.282, 135.372, 99.5116, 138.112, + 98.7516, 140.852, 98.0016, 143.592, 97.2616, 146.342, 96.4816, 149.082, 95.3316, 151.672, 93.5216, 153.852, 91.1616, 155.412, 88.4416, 156.242, + 85.6116, 156.322, 82.8516, 155.672, 80.3616, 154.302, 78.3616, 152.292, 77.0216, 149.792, 76.4116, 147.022, 76.6016, 144.192, 77.2816, 141.432, + 78.0516, 138.692, 78.8216, 135.952, 79.5816, 133.212, 80.3416, 130.462, 81.1116, 127.722, 81.8816, 124.982, 82.6516, 122.252, 83.4316, 119.512, + 84.2116, 116.772, 84.9816, 114.032, 85.7716, 111.302, 86.5416, 108.562, 87.7116, 105.972, 89.5316, 103.802, 91.9116, 102.262, 94.6216, 101.432, + 137.402, 92.4021, 140.072, 92.9121, 142.512, 94.1321, 144.562, 95.9221, 146.362, 97.9821, 148.132, 100.062, 149.882, 102.162, 151.612, 104.272, + 153.322, 106.402, 155.012, 108.542, 156.682, 110.702, 158.332, 112.882, 159.952, 115.082, 161.542, 117.302, 163.092, 119.552, 164.602, 121.832, + 166.062, 124.132, 167.232, 126.592, 167.802, 129.262, 167.762, 131.992, 167.102, 134.632, 165.832, 137.042, 163.972, 139.022, 161.602, 140.362, + 158.922, 140.792, 156.272, 140.192, 154.022, 138.682, 152.292, 136.562, 150.782, 134.292, 149.252, 132.032, 147.692, 129.792, 146.102, 127.572, + 144.482, 125.362, 142.852, 123.172, 141.192, 121.012, 139.502, 118.862, 137.792, 116.732, 136.062, 114.612, 134.312, 112.522, 132.542, 110.442, + 130.772, 108.362, 129.322, 106.052, 128.522, 103.452, 128.392, 100.732, 128.972, 98.0721, 130.262, 95.6721, 132.222, 93.7821, 134.692, 92.6521, + 88.2061, 91.4802, 91.2561, 91.8302, 94.2261, 92.5902, 97.0561, 93.7702, 99.7061, 95.3202, 102.126, 97.2002, 104.276, 99.4002, 106.106, 101.86, + 107.586, 104.55, 108.686, 107.41, 109.406, 110.39, 109.736, 113.44, 109.676, 116.51, 109.226, 119.55, 108.396, 122.5, 107.196, 125.33, + 105.646, 127.97, 103.776, 130.41, 101.616, 132.59, 99.2061, 134.48, 96.5761, 136.07, 93.7661, 137.31, 90.8261, 138.19, 87.7961, 138.68, + 84.7261, 138.75, 81.6761, 138.41, 78.7061, 137.65, 75.8761, 136.47, 73.2261, 134.93, 70.8061, 133.04, 68.6561, 130.85, 66.8261, 128.38, + 65.3561, 125.69, 64.2461, 122.83, 63.5161, 119.85, 63.1961, 116.8, 63.2561, 113.73, 63.7161, 110.7, 64.5461, 107.74, 65.7461, 104.92, + 67.2961, 102.27, 69.1661, 99.8302, 71.3261, 97.6502, 73.7361, 95.7602, 76.3661, 94.1702, 79.1661, 92.9302, 82.1061, 92.0502, 85.1361, 91.5602, + 153.226, 117.32, 154.806, 117.37, 156.356, 117.66, 157.846, 118.19, 159.236, 118.93, 160.506, 119.86, 161.646, 120.96, 162.626, 122.19, + 163.456, 123.53, 164.126, 124.96, 164.626, 126.46, 164.946, 128, 165.086, 129.58, 165.046, 131.15, 164.806, 132.71, 164.376, 134.23, + 163.776, 135.69, 163.016, 137.07, 162.076, 138.34, 160.976, 139.47, 159.746, 140.46, 158.386, 141.26, 156.926, 141.87, 155.406, 142.26, + 153.836, 142.44, 152.256, 142.38, 150.706, 142.09, 149.226, 141.57, 147.826, 140.83, 146.556, 139.89, 145.426, 138.8, 144.436, 137.57, + 143.606, 136.23, 142.936, 134.8, 142.446, 133.3, 142.116, 131.75, 141.976, 130.18, 142.016, 128.61, 142.256, 127.04, 142.676, 125.52, + 143.276, 124.07, 144.056, 122.69, 144.996, 121.42, 146.086, 120.29, 147.326, 119.31, 148.676, 118.5, 150.136, 117.89, 151.666, 117.5, + 85.7174, 97.5919, 88.7474, 97.8219, 91.5574, 98.9719, 93.9574, 100.832, 95.8274, 103.232, 97.0974, 106.002, 97.8774, 108.952, 98.3774, 111.952, + 98.8074, 114.972, 99.3174, 117.982, 99.8874, 120.972, 100.497, 123.962, 101.137, 126.942, 101.827, 129.912, 102.557, 132.872, 103.327, 135.822, + 104.157, 138.762, 105.037, 141.682, 105.897, 144.602, 106.417, 147.602, 106.157, 150.632, 105.017, 153.442, 102.987, 155.692, 100.277, 157.052, + 97.2574, 157.432, 94.2474, 157.002, 91.4274, 155.882, 88.8974, 154.172, 86.7674, 152.002, 85.1174, 149.452, 83.9674, 146.622, 83.0774, 143.712, + 82.2674, 140.772, 81.4674, 137.832, 80.7074, 134.872, 79.9874, 131.912, 79.3074, 128.942, 78.6674, 125.962, 78.0774, 122.972, 77.5174, 119.972, + 76.9974, 116.962, 76.5174, 113.952, 76.1074, 110.932, 76.1074, 107.892, 76.7974, 104.922, 78.1774, 102.212, 80.2074, 99.9519, 82.7774, 98.3419, + 145.187, 87.0219, 148.307, 87.4019, 151.217, 88.5819, 153.727, 90.4719, 155.707, 92.9119, 156.987, 95.7919, 157.787, 98.8319, 158.497, 101.902, + 159.197, 104.972, 159.887, 108.052, 160.587, 111.122, 161.287, 114.192, 161.977, 117.272, 162.667, 120.342, 163.347, 123.422, 164.057, 126.492, + 164.747, 129.562, 165.397, 132.652, 165.607, 135.782, 164.987, 138.862, 163.567, 141.662, 161.467, 144.012, 158.887, 145.792, 155.957, 146.942, + 152.847, 147.392, 149.717, 147.062, 146.787, 145.932, 144.257, 144.072, 142.307, 141.612, 141.107, 138.702, 140.377, 135.642, 139.687, 132.562, + 139.007, 129.492, 138.317, 126.412, 137.627, 123.342, 136.937, 120.262, 136.247, 117.192, 135.547, 114.122, 134.857, 111.042, 134.157, 107.972, + 133.447, 104.902, 132.757, 101.832, 132.567, 98.6919, 133.157, 95.6019, 134.527, 92.7819, 136.577, 90.3919, 139.147, 88.5819, 142.067, 87.4319, + 94.4623, 77.3766, 98.8723, 78.0766, 103.022, 79.7266, 106.692, 82.2566, 109.652, 85.5866, 111.732, 89.5266, 112.832, 93.8566, 112.812, 98.3166, + 111.872, 102.677, 110.652, 106.977, 109.402, 111.277, 108.152, 115.567, 106.902, 119.857, 105.632, 124.147, 104.352, 128.437, 103.062, 132.717, + 101.762, 136.987, 100.472, 141.267, 99.1723, 145.547, 97.7623, 149.787, 95.5523, 153.657, 92.1823, 156.567, 88.1323, 158.417, 83.7523, 159.287, + 79.2923, 159.267, 74.8923, 158.487, 70.6823, 156.997, 66.8123, 154.777, 63.5123, 151.777, 61.1223, 148.017, 60.0223, 143.707, 60.5823, 139.297, + 61.9523, 135.037, 63.2823, 130.767, 64.5923, 126.497, 65.9023, 122.217, 67.1923, 117.937, 68.4623, 113.657, 69.7323, 109.367, 70.9923, 105.077, + 72.2423, 100.787, 73.4823, 96.4866, 74.6823, 92.1766, 76.2123, 87.9866, 78.6423, 84.2466, 81.8723, 81.1666, 85.7323, 78.9366, 90.0123, 77.6566, + 161.902, 85.0066, 166.112, 85.3266, 169.942, 87.0866, 172.902, 90.0966, 174.772, 93.8866, 175.582, 98.0366, 175.472, 102.277, 174.922, 106.477, + 174.262, 110.667, 173.502, 114.847, 172.622, 118.987, 171.622, 123.117, 170.512, 127.207, 169.282, 131.267, 167.932, 135.287, 166.432, 139.257, + 164.782, 143.167, 162.972, 146.997, 160.972, 150.737, 158.562, 154.217, 155.642, 157.287, 152.332, 159.937, 148.712, 162.147, 144.842, 163.887, + 140.782, 165.077, 136.572, 165.537, 132.402, 164.897, 129.022, 162.457, 128.042, 158.407, 129.192, 154.357, 131.112, 150.577, 132.952, 146.757, + 134.642, 142.857, 136.192, 138.907, 137.602, 134.917, 138.882, 130.867, 140.032, 126.787, 141.062, 122.677, 141.972, 118.527, 142.772, 114.367, + 143.412, 110.167, 144.002, 105.967, 144.832, 101.817, 146.172, 97.7966, 148.202, 94.0766, 150.872, 90.7866, 154.082, 88.0266, 157.792, 85.9966, + 81.5951, 102.995, 84.9251, 103.415, 88.1651, 104.325, 91.2451, 105.685, 94.1251, 107.415, 96.7751, 109.475, 99.1751, 111.825, 101.325, 114.415, + 103.185, 117.215, 104.775, 120.175, 106.065, 123.285, 107.055, 126.495, 107.725, 129.795, 108.055, 133.135, 108.025, 136.505, 107.615, 139.835, + 106.795, 143.095, 105.555, 146.225, 103.905, 149.145, 101.835, 151.795, 99.3651, 154.075, 96.5451, 155.905, 93.4451, 157.195, 90.1651, 157.895, + 86.8051, 158.025, 83.4651, 157.605, 80.2351, 156.685, 77.1551, 155.335, 74.2751, 153.605, 71.6251, 151.535, 69.2151, 149.185, 67.0751, 146.595, + 65.2051, 143.795, 63.6251, 140.835, 62.3251, 137.735, 61.3351, 134.515, 60.6751, 131.225, 60.3451, 127.875, 60.3751, 124.515, 60.7851, 121.175, + 61.6051, 117.915, 62.8351, 114.795, 64.4951, 111.865, 66.5651, 109.215, 69.0251, 106.935, 71.8451, 105.115, 74.9551, 103.825, 78.2351, 103.125, + 156.055, 86.3046, 159.725, 86.5546, 163.325, 87.2846, 166.805, 88.4746, 170.105, 90.1046, 173.145, 92.1746, 175.895, 94.6146, 178.295, 97.4046, + 180.305, 100.485, 181.885, 103.805, 183.025, 107.305, 183.705, 110.915, 183.905, 114.585, 183.615, 118.255, 182.835, 121.855, 181.595, 125.315, + 179.925, 128.595, 177.855, 131.625, 175.415, 134.385, 172.645, 136.805, 169.595, 138.865, 166.305, 140.505, 162.825, 141.705, 159.225, 142.435, + 155.555, 142.675, 151.885, 142.425, 148.275, 141.705, 144.795, 140.515, 141.505, 138.875, 138.455, 136.815, 135.705, 134.375, 133.305, 131.585, + 131.295, 128.505, 129.715, 125.185, 128.575, 121.685, 127.895, 118.075, 127.695, 114.395, 127.995, 110.735, 128.765, 107.135, 130.005, 103.675, + 131.675, 100.395, 133.755, 97.3646, 136.195, 94.6046, 138.955, 92.1846, 142.005, 90.1246, 145.295, 88.4746, 148.775, 87.2746, 152.385, 86.5446, + 68.7636, 106.401, 71.3536, 106.711, 73.9436, 107.011, 76.5436, 107.321, 79.1336, 107.641, 81.7236, 107.981, 84.3036, 108.331, 86.8936, 108.701, + 89.4736, 109.071, 92.0536, 109.461, 94.6336, 109.861, 97.2136, 110.281, 99.7936, 110.701, 102.364, 111.141, 104.934, 111.591, 107.514, 111.991, + 110.074, 112.511, 112.414, 113.631, 114.174, 115.541, 115.104, 117.961, 114.994, 120.551, 113.894, 122.901, 111.994, 124.671, 109.584, 125.631, + 106.984, 125.651, 104.424, 125.171, 101.854, 124.711, 99.2736, 124.261, 96.7036, 123.831, 94.1236, 123.401, 91.5436, 123.001, 88.9636, 122.601, + 86.3836, 122.211, 83.8036, 121.841, 81.2136, 121.471, 78.6236, 121.131, 76.0436, 120.791, 73.4436, 120.481, 70.8536, 120.181, 68.2636, 119.881, + 65.6636, 119.571, 63.2036, 118.761, 61.4136, 116.901, 60.6736, 114.421, 60.9236, 111.831, 62.0036, 109.461, 63.8036, 107.601, 66.1636, 106.521, + 133.254, 117.321, 135.754, 117.781, 138.224, 118.411, 140.704, 119.001, 143.174, 119.601, 145.644, 120.211, 148.114, 120.841, 150.574, 121.471, + 153.044, 122.121, 155.504, 122.781, 157.954, 123.451, 160.404, 124.141, 162.854, 124.831, 165.304, 125.541, 167.744, 126.271, 170.174, 127.011, + 172.614, 127.741, 174.914, 128.811, 176.604, 130.691, 177.544, 133.051, 177.734, 135.581, 177.114, 138.031, 175.634, 140.081, 173.404, 141.261, + 170.884, 141.201, 168.444, 140.481, 166.004, 139.731, 163.564, 139.001, 161.124, 138.291, 158.674, 137.591, 156.224, 136.911, 153.764, 136.241, + 151.304, 135.591, 148.844, 134.951, 146.374, 134.331, 143.904, 133.711, 141.434, 133.101, 138.964, 132.491, 136.484, 131.881, 134.014, 131.281, + 131.544, 130.681, 129.204, 129.691, 127.384, 127.931, 126.354, 125.621, 126.194, 123.091, 126.914, 120.661, 128.484, 118.681, 130.734, 117.531, + 87.0922, 97.8489, 90.3022, 98.3689, 93.3022, 99.6589, 95.8922, 101.629, 97.9622, 104.149, 99.3322, 107.109, 100.082, 110.289, 100.582, 113.519, + 101.082, 116.749, 101.582, 119.969, 102.102, 123.199, 102.632, 126.429, 103.162, 129.649, 103.712, 132.869, 104.262, 136.089, 104.842, 139.309, + 105.442, 142.519, 105.992, 145.739, 106.082, 148.999, 105.242, 152.149, 103.572, 154.939, 101.182, 157.149, 98.3022, 158.679, 95.1522, 159.529, + 91.8922, 159.699, 88.6722, 159.219, 85.6222, 158.069, 82.9122, 156.239, 80.7622, 153.799, 79.3922, 150.839, 78.6522, 147.659, 78.0822, 144.439, + 77.5122, 141.229, 76.9522, 138.009, 76.4022, 134.789, 75.8722, 131.559, 75.3422, 128.339, 74.8222, 125.109, 74.3122, 121.879, 73.8022, 118.659, + 73.3022, 115.429, 72.8622, 112.189, 73.0022, 108.929, 73.9122, 105.809, 75.5922, 103.009, 77.9222, 100.729, 80.7222, 99.0689, 83.8322, 98.0989, + 169.252, 103.619, 171.402, 104.369, 172.842, 106.159, 173.452, 108.389, 173.362, 110.699, 172.632, 112.889, 171.262, 114.749, 169.322, 115.999, + 167.162, 116.849, 164.992, 117.659, 162.822, 118.489, 160.652, 119.289, 158.472, 120.089, 156.282, 120.859, 154.092, 121.609, 151.892, 122.349, + 149.682, 123.059, 147.472, 123.749, 145.252, 124.419, 143.022, 125.079, 140.792, 125.709, 138.552, 126.319, 136.312, 126.909, 134.062, 127.469, + 131.762, 127.639, 129.612, 126.839, 128.052, 125.149, 127.292, 122.969, 127.352, 120.659, 128.202, 118.519, 129.742, 116.799, 131.782, 115.709, + 134.012, 115.089, 136.252, 114.499, 138.492, 113.879, 140.722, 113.249, 142.952, 112.589, 145.172, 111.919, 147.382, 111.239, 149.592, 110.529, + 151.802, 109.809, 153.992, 109.069, 156.192, 108.309, 158.372, 107.529, 160.552, 106.739, 162.722, 105.919, 164.882, 105.069, 167.022, 104.189, + 92.5127, 100.988, 94.6127, 101.058, 96.6727, 101.488, 98.6127, 102.288, 100.383, 103.428, 101.903, 104.878, 103.133, 106.578, 104.043, 108.478, + 104.553, 110.508, 104.673, 112.608, 104.513, 114.708, 104.233, 116.788, 103.933, 118.878, 103.653, 120.958, 103.383, 123.048, 103.103, 125.138, + 102.813, 127.218, 102.493, 129.298, 101.973, 131.338, 101.073, 133.238, 99.8127, 134.908, 98.2327, 136.288, 96.4027, 137.318, 94.4027, 137.978, + 92.3227, 138.268, 90.2227, 138.178, 88.1727, 137.728, 86.2227, 136.928, 84.4627, 135.788, 82.9227, 134.348, 81.6727, 132.668, 80.7427, 130.778, + 80.1827, 128.758, 80.0227, 126.658, 80.1727, 124.568, 80.4627, 122.478, 80.7927, 120.398, 81.0927, 118.318, 81.3827, 116.238, 81.6827, 114.148, + 81.9727, 112.068, 82.2927, 109.988, 82.8527, 107.958, 83.7727, 106.068, 85.0427, 104.398, 86.6227, 103.018, 88.4527, 101.978, 90.4327, 101.298, + 149.203, 106.748, 151.253, 106.888, 153.223, 107.448, 155.033, 108.418, 156.593, 109.748, 157.843, 111.378, 158.733, 113.228, 159.243, 115.208, + 159.383, 117.258, 159.243, 119.308, 158.963, 121.348, 158.683, 123.378, 158.423, 125.418, 158.153, 127.458, 157.863, 129.498, 157.543, 131.518, + 157.163, 133.548, 156.593, 135.518, 155.723, 137.378, 154.573, 139.078, 153.183, 140.588, 151.583, 141.868, 149.793, 142.878, 147.863, 143.558, + 145.833, 143.868, 143.783, 143.778, 141.793, 143.278, 139.953, 142.378, 138.363, 141.078, 137.133, 139.438, 136.303, 137.558, 135.893, 135.548, + 135.903, 133.498, 136.213, 131.468, 136.533, 129.438, 136.853, 127.408, 137.143, 125.378, 137.403, 123.338, 137.623, 121.288, 137.863, 119.248, + 138.163, 117.218, 138.633, 115.218, 139.423, 113.328, 140.513, 111.588, 141.873, 110.048, 143.463, 108.738, 145.243, 107.728, 147.173, 107.038, + ] + static let gazeFlat: [CGFloat] = [ + 41.1551, -60.5302, 0.824375, 25.4941, 11.3477, 30.8925, -39.68, -0.955729, 23.5902, -5.85979, -2.89208, 32.071, 25.2746, 6.29417, 25.9939, -26.899, + -39.807, -8.73021, 21.7151, 26.8792, 9.25167, -7.03292, -34.3154, 5.74958, 18.1492, 38.3474, -20.2271, 12.1602, -11.114, -18.779, 70.1532, -21.4929, + -49.2559, 20.1061, 17.9684, -13.7621, 4.50385, -15.2402, -27.7374, 14.4381, 19.2877, 42.7034, -29.1651, 8.88542, 3.57635, -27.0711, 19.9578, 18.3411, + 6.10729, 20.5418, + ] + static let mouthsFlat: [CGFloat] = [ + 14, 3, 20, 0, 13, 1, 20, 0, 25, 14, 13, 0, 11, 4, 18, 0, + 16, -2, 22, 0, 15, -3, 20, 6, 12, 2, 20, 0, 17, -7, 18, 0, + 15, 5, 20, 0, 12, 3, 20, 0, 14, 1, 20, 0, 23, 12, 13, 0, + 12, 2, 20, 0, 16, -3, 22, 0, 15, -2, 20, -6, 12, 5, 20, 0, + 16, -4, 18, 0, 20, 11, 18, 0, 11, 2, 20, 0, 17, 8, 20, 0, + 15, 5, 10, 0, 12, 4, 18, 0, 16, -3, 22, 0, 15, -5, 20, 7, + 11, 6, 20, 0, + ] + + static func ring(_ expression: Int, eye: Int) -> [CGPoint] { + let base = (expression * 2 + eye) * pointsPerRing * 2 + return (0.. CGPoint { CGPoint(x: gazeFlat[expression * 2], y: gazeFlat[expression * 2 + 1]) } + /// [halfWidth, curve, gap, skew] + static func mouth(_ expression: Int) -> [CGFloat] { Array(mouthsFlat[(expression * 4)..<(expression * 4 + 4)]) } + + static let pools: [MausState: [Int]] = [ + .sleeping: [22, 13, 4], + .waking: [13], + .idle: [6, 0, 8], + .listening: [1, 10, 19], + .thinking: [17, 8, 16, 14, 5], + .searching: [20, 15, 9, 3, 12, 18], + .working: [10, 7, 16, 11], + .excited: [2, 17, 21, 3, 11], + .surprised: [21, 3], + .suspicious: [5, 14, 23], + .angry: [7, 16], + .drowsy: [22, 4, 13], + .happy: [19, 2, 11, 17], + .curious: [21, 3, 0, 15], + .confused: [8, 14, 5], + .bored: [0, 4, 22], + .proud: [2, 15, 8], + .shy: [24, 0, 13], + .sad: [22, 4, 13], + .laughing: [2, 11, 17], + .scared: [21, 3], + .playful: [2, 17, 11, 8], + .celebrate: [2, 8, 17], + .orbit: [6, 0, 8], + .radar: [6, 0, 8], + .progress: [6, 0, 8], + .thinkingDots: [6, 0, 8], + .spawning: [3, 0], + .humming: [6, 0, 8], + .loading: [6, 0, 8], + .dictating: [1, 10, 19], + .sending: [6, 0, 8], + .receiving: [19, 0, 8], + .uploading: [15, 9, 8], + .writing: [15, 9], + .notifying: [21, 3, 0], + .alerting: [21, 3], + .bouncing: [2, 17], + .dragging: [3, 15, 0], + .poweringDown: [22, 13], + ] + /// How long a state holds an expression, ms. + static let expressionCadence: [MausState: (CGFloat, CGFloat)] = [ + .sleeping: (6000, 10000), + .waking: (800, 800), + .idle: (9000, 16000), + .listening: (2800, 5000), + .thinking: (2000, 3600), + .searching: (1000, 1800), + .working: (1800, 3200), + .excited: (1100, 2000), + .surprised: (2500, 4000), + .suspicious: (2600, 4500), + .angry: (2200, 3800), + .drowsy: (4000, 8000), + .happy: (2500, 4500), + .curious: (1800, 3200), + .confused: (2200, 3800), + .bored: (3500, 6000), + .proud: (3500, 6000), + .shy: (3000, 5500), + .sad: (4000, 7000), + .laughing: (1200, 2400), + .scared: (900, 1800), + .playful: (1500, 3000), + .celebrate: (1400, 2600), + .orbit: (4000, 8000), + .radar: (4000, 8000), + .progress: (4000, 8000), + .thinkingDots: (4000, 8000), + .spawning: (1200, 1200), + .humming: (5000, 9000), + .loading: (6000, 10000), + .dictating: (4000, 8000), + .sending: (4000, 8000), + .receiving: (4000, 8000), + .uploading: (4000, 8000), + .writing: (4000, 8000), + .notifying: (1500, 2600), + .alerting: (2000, 3600), + .bouncing: (3000, 6000), + .dragging: (1600, 3000), + .poweringDown: (6000, 9000), + ] + /// Blink cadence, ms; absent = never blinks. + static let blink: [MausState: (CGFloat, CGFloat)] = [ + .idle: (6000, 14000), + .listening: (3000, 7000), + .thinking: (3500, 7000), + .searching: (1600, 4000), + .working: (2800, 5500), + .excited: (2000, 4000), + .surprised: (1800, 3500), + .suspicious: (4500, 8000), + .angry: (3500, 7000), + .happy: (2500, 5000), + .curious: (2500, 5500), + .confused: (2800, 5500), + .bored: (4000, 8000), + .proud: (3500, 7000), + .shy: (3000, 6000), + .sad: (4000, 8000), + .laughing: (2500, 5000), + .scared: (1200, 3000), + .playful: (2000, 4500), + .celebrate: (2200, 4500), + .humming: (4000, 8000), + .notifying: (2000, 4000), + .dragging: (2200, 4500), + ] + static let motion: [MausState: MausBodyMotion] = [ + .sleeping: MausBodyMotion(pulse: (0.028, 4600), tilt: 2), + .waking: MausBodyMotion(pulse: (0.03, 2200), enter: (0.92, 700)), + .idle: MausBodyMotion(pulse: (0.014, 3600)), + .listening: MausBodyMotion(bob: (2, 2600), pulse: (0.012, 2600)), + .thinking: MausBodyMotion(sway: (1.6, 3000), pulse: (0.01, 3000)), + .searching: MausBodyMotion(bob: (3, 1400), sway: (2.2, 1400)), + .working: MausBodyMotion(bob: (2.5, 900), squash: 0.22), + .excited: MausBodyMotion(bob: (9, 520), sway: (3, 1040), squash: 0.35), + .surprised: MausBodyMotion(jitter: (0.8, 120), enter: (1.14, 340)), + .suspicious: MausBodyMotion(sway: (2.4, 2600), tilt: -3), + .angry: MausBodyMotion(jitter: (1.3, 95), tilt: 2), + .drowsy: MausBodyMotion(pulse: (0.026, 5000), tilt: 3), + .happy: MausBodyMotion(bob: (5, 820), squash: 0.28), + .curious: MausBodyMotion(sway: (3.4, 1900), tilt: -4), + .confused: MausBodyMotion(sway: (3, 2200)), + .bored: MausBodyMotion(pulse: (0.016, 5200), tilt: 2), + .proud: MausBodyMotion(bob: (1.6, 2400), pulse: (0.02, 2400)), + .shy: MausBodyMotion(pulse: (0.016, 3000), tilt: 4), + .sad: MausBodyMotion(pulse: (0.02, 4600), tilt: 3), + .laughing: MausBodyMotion(bob: (7, 430), squash: 0.4), + .scared: MausBodyMotion(jitter: (2.2, 75)), + .playful: MausBodyMotion(bob: (6, 620), sway: (5, 1240), squash: 0.3), + .celebrate: MausBodyMotion(bob: (10, 480), sway: (4, 960), squash: 0.35), + .orbit: MausBodyMotion(circle: (6, 3200)), + .radar: MausBodyMotion(sway: (6, 2400), pulse: (0.012, 2400)), + .progress: MausBodyMotion(pulse: (0.022, 1600)), + .thinkingDots: MausBodyMotion(), + .spawning: MausBodyMotion(pulse: (0.014, 3600), enter: (0.02, 820)), + .humming: MausBodyMotion(pulse: (0.016, 2800)), + .loading: MausBodyMotion(sway: (2.2, 1500), pulse: (0.012, 1500)), + .dictating: MausBodyMotion(bob: (2, 2000)), + .sending: MausBodyMotion(), + .receiving: MausBodyMotion(), + .uploading: MausBodyMotion(bob: (3, 1000)), + .writing: MausBodyMotion(bob: (1.6, 1100)), + .notifying: MausBodyMotion(bob: (4, 700), sway: (2.5, 700)), + .alerting: MausBodyMotion(jitter: (2.6, 85)), + .bouncing: MausBodyMotion(bob: (12, 560), squash: 0.45), + .dragging: MausBodyMotion(sway: (2, 900), tilt: -6), + .poweringDown: MausBodyMotion(tilt: 4, settle: 0.05), + ] +} diff --git a/ios/App/NewGroupSheet.swift b/ios/App/NewGroupSheet.swift new file mode 100644 index 000000000..5d371b876 --- /dev/null +++ b/ios/App/NewGroupSheet.swift @@ -0,0 +1,74 @@ +// Make a room from the phone: a name, and which bots are in it. +// +// The harness names it after the first member if the name is left blank, +// which is what the desktop's dialog does too — one rule, two screens. +import SwiftUI +import CompanionCore + +struct NewGroupSheet: View { + let created: (Room) -> Void + @EnvironmentObject private var session: Session + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var members = Set() + @State private var creating = false + + private var bots: [Bot] { session.state.bots.filter { $0.hidden != true } } + + var body: some View { + NavigationStack { + List { + Section { + TextField("Group name (optional)", text: $name) + .autocorrectionDisabled() + } + Section("Bots") { + ForEach(bots) { bot in + Button { + if members.contains(bot.id) { members.remove(bot.id) } else { members.insert(bot.id) } + } label: { + HStack(spacing: 12) { + MausAvatar(color: bot.color, size: 36, state: .idle, animated: false) + VStack(alignment: .leading, spacing: 2) { + Text(bot.name).font(.system(size: 16, weight: .semibold)).foregroundStyle(Color.primary) + if !bot.title.isEmpty { + Text(bot.title).font(.system(size: 13)).foregroundStyle(Color.secondary) + } + } + Spacer() + Image(systemName: members.contains(bot.id) ? "checkmark.circle.fill" : "circle") + .font(.system(size: 22)) + .foregroundStyle(members.contains(bot.id) ? MausPalette.color(bot.color) : Color.secondary.opacity(0.4)) + } + } + .buttonStyle(.plain) + } + } + } + .navigationTitle("New group") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Create") { + creating = true + Task { + // members in roster order, so the room's name (if + // it defaults) follows the first bot you picked + let ordered = bots.map(\.id).filter(members.contains) + if let room = await session.createRoom(name: name, memberIds: ordered) { + created(room) + } + creating = false + } + } + .disabled(members.isEmpty || creating) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } +} diff --git a/ios/App/Session.swift b/ios/App/Session.swift index 0de8b8954..2154e7e41 100644 --- a/ios/App/Session.swift +++ b/ios/App/Session.swift @@ -199,6 +199,8 @@ final class Session: ObservableObject { // purpose. Coming to the front is the moment worth retrying on: the // app is on screen, so the phone is in someone's hand and unlocked. if client == nil, restorePending { restore() } + // back before the grace period ran out: keep the stream, drop the task + endLinger() guard client != nil, streamTask == nil else { return } reconnectDelay = 0 streamGeneration += 1 @@ -264,6 +266,33 @@ final class Session: ObservableObject { func disconnect() { streamTask?.cancel() streamTask = nil + endLinger() + } + + private var lingerTask: UIBackgroundTaskIdentifier = .invalid + + /// Leaving the screen: keep the stream alive for the grace period iOS + /// allows (~30 s) rather than cutting it at once, so an approval that + /// lands right after you swipe home still reaches the Live Activity and + /// the island. After that, iOS suspends us anyway; disconnect cleanly so + /// the cursor is written down at a known point. + func linger() { + guard streamTask != nil, lingerTask == .invalid else { disconnect(); return } + lingerTask = UIApplication.shared.beginBackgroundTask(withName: "companion.linger") { [weak self] in + // time is up before our own timer — the system wants us gone now + self?.disconnect() + } + Task { [weak self] in + try? await Task.sleep(for: .seconds(25)) + guard let self, self.lingerTask != .invalid else { return } + self.disconnect() + } + } + + private func endLinger() { + guard lingerTask != .invalid else { return } + UIApplication.shared.endBackgroundTask(lingerTask) + lingerTask = .invalid } private func run() async { @@ -412,10 +441,16 @@ final class Session: ObservableObject { func answer(threadId: String, card: OptionCard, choice: String) async { guard let requestId = card.requestId else { return } + await answer(threadId: threadId, requestId: requestId, choice: choice, isPermission: card.isPermission) + } + + /// The same answer, from something that only has the ids — the Live + /// Activity's buttons. + func answer(threadId: String, requestId: String, choice: String, isPermission: Bool) async { await perform { // Permission cards answer allow/deny; a question answers with // the chosen text. The harness tells them apart by `behavior`. - if card.isPermission { + if isPermission { try await $0.respond( threadId: threadId, requestId: requestId, @@ -455,6 +490,21 @@ final class Session: ObservableObject { } } + /// Make a room from the phone. Same shape as `createBot`: fold it in + /// rather than wait for a broadcast, and hand it back so it can be opened. + @discardableResult + func createRoom(name: String?, memberIds: [String]) async -> Room? { + guard let client else { return nil } + do { + let room = try await client.createRoom(name: name, memberIds: memberIds) + state.apply(.room(room)) + return room + } catch { + actionError = error.localizedDescription + return nil + } + } + func interrupt(bot: Bot) async { await perform { try await $0.interrupt(botId: bot.id) } } @@ -766,7 +816,11 @@ extension CompanionState { guard let last else { return "" } switch last.kind { case .text: return last.text ?? "" - case .options: return last.card?.isPending == true ? "Waiting on you" : (last.card?.title ?? "") + // a pending card's question is the preview; the roster row already + // says "waiting on you" beside it + case .options: + guard let card = last.card else { return "" } + return card.isPending && !card.subtitle.isEmpty ? card.subtitle : card.title case .activity: return last.tool?.name ?? "" case .screen: return "Screenshot" case .unknown: return last.text ?? "" diff --git a/ios/App/SpeechBubble.swift b/ios/App/SpeechBubble.swift new file mode 100644 index 000000000..f1d87942e --- /dev/null +++ b/ios/App/SpeechBubble.swift @@ -0,0 +1,101 @@ +// The message bubble, tail included. +// +// The tail is not an approximation: its four Bézier segments are the ones +// from the reference vector (`docs/ios/bubble-tail-reference.svg`), scaled +// so the reference's cap radius becomes this bubble's corner radius. That is why it curls in, drops, and ends soft rather than in a +// point — the shape is the drawing, not a guess at it. +import SwiftUI + +struct SpeechBubble: Shape { + enum Tail { case none, leading, trailing } + + var tail: Tail = .none + var cornerRadius: CGFloat = 22 + + /// How far below the bubble's bottom edge the tail reaches, so callers can + /// leave room for it. Depends on the corner radius the same way the tail + /// does. + static func tailDrop(cornerRadius: CGFloat = 22) -> CGFloat { + cornerRadius / referenceCapRadius * referenceTailDrop + } + + // The reference: a 772×428 pill whose right cap has this radius, whose + // right edge is at x=766.342 and bottom edge at y=361.317, and whose tail + // hangs 68 below the bottom edge. + private static let referenceCapRadius: CGFloat = 179 + private static let referenceTailDrop: CGFloat = 68 + private static let referenceOrigin = CGPoint(x: 766.342, y: 361.317) + private static let referenceTail: [(CGPoint, CGPoint, CGPoint)] = [ + (CGPoint(x: 766.342, y: 249.767), CGPoint(x: 727.342, y: 299.817), CGPoint(x: 699.842, y: 323.317)), + (CGPoint(x: 699.842, y: 323.317), CGPoint(x: 677.085, y: 338.817), CGPoint(x: 674.342, y: 354.817)), + (CGPoint(x: 668.342, y: 389.817), CGPoint(x: 700.829, y: 410.817), CGPoint(x: 691.829, y: 421.317)), + (CGPoint(x: 684.629, y: 429.717), CGPoint(x: 615.056, y: 378.235), CGPoint(x: 580.342, y: 361.317)), + ] + + func path(in rect: CGRect) -> Path { + let r = min(cornerRadius, rect.height / 2, rect.width / 2) + guard tail != .none else { + return RoundedRectangle(cornerRadius: r, style: .continuous).path(in: rect) + } + var path = trailingTailPath(in: rect, radius: r) + if tail == .leading { + // Mirror the trailing shape about the rect's vertical centre line. + let flip = CGAffineTransform(translationX: rect.minX + rect.maxX, y: 0).scaledBy(x: -1, y: 1) + path = path.applying(flip) + } + return path + } + + /// The bubble with the tail at bottom-right, drawn clockwise from the top + /// edge. The three plain corners are circular arcs; the fourth is the + /// reference's cap-and-tail, scaled. + private func trailingTailPath(in rect: CGRect, radius r: CGFloat) -> Path { + let s = r / Self.referenceCapRadius + let origin = CGPoint(x: rect.maxX, y: rect.maxY) + func map(_ p: CGPoint) -> CGPoint { + CGPoint(x: origin.x + (p.x - Self.referenceOrigin.x) * s, y: origin.y + (p.y - Self.referenceOrigin.y) * s) + } + + var path = Path() + path.move(to: CGPoint(x: rect.minX + r, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY)) + path.addArc( + center: CGPoint(x: rect.maxX - r, y: rect.minY + r), radius: r, + startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false + ) + // Down the right edge to where the reference cap begins — exactly one + // radius above the bottom, which is where the first segment starts. + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - r)) + for (c1, c2, end) in Self.referenceTail { + path.addCurve(to: map(end), control1: map(c1), control2: map(c2)) + } + // The last segment lands back on the bottom edge, left of the corner. + path.addLine(to: CGPoint(x: rect.minX + r, y: rect.maxY)) + path.addArc( + center: CGPoint(x: rect.minX + r, y: rect.maxY - r), radius: r, + startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false + ) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r)) + path.addArc( + center: CGPoint(x: rect.minX + r, y: rect.minY + r), radius: r, + startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false + ) + path.closeSubpath() + return path + } +} + +/// The two bubble fills. Solid rather than translucent on purpose: the tail +/// is part of the same fill, and a see-through bubble shows the seam. +enum BubbleColor { + /// What you said. The mascot palette's blue, not the system's. + static let mine = MausPalette.color("blue") + static let mineText = Color.white + + /// What a bot said. Near-black on dark, a soft grey on light. + static let theirs = Color(uiColor: UIColor { trait in + trait.userInterfaceStyle == .dark + ? UIColor(red: 0.149, green: 0.149, blue: 0.161, alpha: 1) // #262629 + : UIColor(red: 0.914, green: 0.914, blue: 0.922, alpha: 1) // #E9E9EB + }) +} diff --git a/ios/App/Updates.swift b/ios/App/Updates.swift new file mode 100644 index 000000000..a5b09e1be --- /dev/null +++ b/ios/App/Updates.swift @@ -0,0 +1,89 @@ +// What the Updates pill shows: only the chats doing something. +// +// Three kinds, in the order a person cares about them — a bot that has +// stopped and needs an answer, a bot mid-turn, and a bot that finished with +// something you have not read. A bot that is idle and read is not an update +// and never appears here; that is what the roster below is for. +import Foundation +import CompanionCore + +struct ChatUpdate: Identifiable, Hashable { + enum Kind: Int, Comparable { + case needsYou = 0, working, toReview + static func < (a: Kind, b: Kind) -> Bool { a.rawValue < b.rawValue } + } + + let chat: Chat + let kind: Kind + /// One line under the name — the question, what it is doing, or what it said. + let line: String + /// The card to answer, when `kind == .needsYou`. + let card: OptionCard? + + var id: String { chat.id } +} + +extension CompanionState { + var updates: [ChatUpdate] { + var out: [ChatUpdate] = [] + var seen = Set() + + // Newest approval first, one per chat: the pill headlines the most + // recent thing that stopped, and the sheet lists the rest. + for pending in pendingApprovals { + guard let chat = chat(forThread: pending.threadId), seen.insert(chat.id).inserted else { continue } + let card = pending.message.card + out.append(ChatUpdate(chat: chat, kind: .needsYou, line: card?.subtitle ?? card?.title ?? "", card: card)) + } + + for bot in bots where bot.hidden != true { + let chat = Chat.bot(bot) + guard !seen.contains(chat.id) else { continue } + if bot.busy == true { + seen.insert(chat.id) + out.append(ChatUpdate(chat: chat, kind: .working, line: workingLine(threadId: bot.threadId), card: nil)) + } else if bot.unread { + seen.insert(chat.id) + out.append(ChatUpdate(chat: chat, kind: .toReview, line: lastLine(threadId: bot.threadId), card: nil)) + } + } + for room in rooms { + let chat = Chat.room(room) + guard !seen.contains(chat.id) else { continue } + if room.busyBotId != nil { + seen.insert(chat.id) + out.append(ChatUpdate(chat: chat, kind: .working, line: workingLine(threadId: room.threadId), card: nil)) + } else if room.unread { + seen.insert(chat.id) + out.append(ChatUpdate(chat: chat, kind: .toReview, line: lastLine(threadId: room.threadId), card: nil)) + } + } + return out.sorted { $0.kind < $1.kind } + } + + func chat(forThread threadId: String) -> Chat? { + if let bot = bot(forThread: threadId) { return .bot(bot) } + if let room = room(forThread: threadId) { return .room(room) } + return nil + } + + private func workingLine(threadId: String) -> String { + if let live = streaming[threadId], !live.isEmpty { + return String(live.suffix(120)).replacingOccurrences(of: "\n", with: " ") + } + if let last = visibleTranscript(forThread: threadId).last, last.kind == .activity, let tool = last.tool { + return tool.name + } + return "Working…" + } + + private func lastLine(threadId: String) -> String { + guard let last = visibleTranscript(forThread: threadId).last else { return "" } + switch last.kind { + case .text, .unknown: return last.text ?? "" + case .options: return last.card?.title ?? "" + case .activity: return last.tool?.name ?? "" + case .screen: return "Screenshot" + } + } +} diff --git a/ios/App/UpdatesSheet.swift b/ios/App/UpdatesSheet.swift new file mode 100644 index 000000000..49b3f88ef --- /dev/null +++ b/ios/App/UpdatesSheet.swift @@ -0,0 +1,157 @@ +// What the Updates pill opens: the active bots, grouped by what they need. +// +// Needs you first, with the answer right there — the phone exists so that +// a stopped bot on the laptop can be un-stopped from wherever you are. +// Then what is working, then what finished while you were not looking. +import SwiftUI +import CompanionCore + +struct UpdatesSheet: View { + let open: (Chat) -> Void + @EnvironmentObject private var session: Session + @Environment(\.dismiss) private var dismiss + + private var updates: [ChatUpdate] { session.state.updates } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline) { + Text("Updates") + .font(.system(size: 22, weight: .bold)) + Spacer() + Text(updates.isEmpty ? "All quiet" : "\(updates.count) active") + .font(.system(size: 13)) + .foregroundStyle(Color.secondary) + } + .padding(.horizontal, 20) + .padding(.top, 22) + .padding(.bottom, 6) + + if updates.isEmpty { + ContentUnavailableView( + "Nothing needs you", + systemImage: "checkmark.circle", + description: Text("When a bot stops for an answer, is mid-task, or finishes something, it shows up here.") + ) + .padding(.top, 24) + } else { + section("Needs you", tint: nil, kind: .needsYou) + section("Working", tint: nil, kind: .working) + section("To review", tint: nil, kind: .toReview) + } + } + .padding(.bottom, 24) + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationBackground(.thinMaterial) + .presentationCornerRadius(28) + } + + @ViewBuilder + private func section(_ title: String, tint: Color?, kind: ChatUpdate.Kind) -> some View { + let items = updates.filter { $0.kind == kind } + if !items.isEmpty { + let color = kind == .needsYou ? MausPalette.color(items[0].chat.color) : Color.secondary + Text(title.uppercased()) + .font(.system(size: 12, weight: .bold)) + .tracking(0.5) + .foregroundStyle(color) + .padding(.horizontal, 20) + .padding(.top, 14) + .padding(.bottom, 2) + + ForEach(items) { update in + UpdateRow(update: update) { open(update.chat) } + } + } + } +} + +private struct UpdateRow: View { + let update: ChatUpdate + let open: () -> Void + @EnvironmentObject private var session: Session + @State private var answering = false + + var body: some View { + Button(action: open) { + HStack(alignment: .top, spacing: 12) { + MausAvatar(color: update.chat.color, size: 40, state: MausState.forChat(update.chat, in: session.state)) + + VStack(alignment: .leading, spacing: 3) { + Text(update.chat.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.primary) + Text(update.line.isEmpty ? " " : update.line) + .font(.system(size: 14)) + .foregroundStyle(Color.secondary) + .lineLimit(update.kind == .needsYou ? 3 : 1) + .multilineTextAlignment(.leading) + + if update.kind == .needsYou, let card = update.card, card.isPending { + // The answers, as pills, exactly the options the card + // offered — never a choice invented here. + HStack(spacing: 8) { + ForEach(card.options, id: \.self) { option in + Button { + answering = true + Task { + await session.answer(threadId: update.chat.threadId, card: card, choice: option) + answering = false + } + } label: { + Text(option) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(CardStyle.isRefusal(option) ? Color.primary : .white) + .padding(.horizontal, 14) + .frame(height: 32) + .background( + Capsule().fill( + CardStyle.isRefusal(option) + ? Color.secondary.opacity(0.18) + : MausPalette.color(update.chat.color) + ) + ) + } + .buttonStyle(.plain) + .disabled(answering) + } + } + .padding(.top, 6) + } + } + + Spacer(minLength: 0) + + switch update.kind { + case .needsYou: + EmptyView() + case .working: + ProgressView().controlSize(.small).padding(.top, 10) + case .toReview: + HStack(spacing: 6) { + Circle().fill(MausPalette.color(update.chat.color)).frame(width: 10, height: 10) + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.secondary.opacity(0.5)) + } + .padding(.top, 12) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +/// One definition of "the refusal", shared by every place a card's options +/// are drawn as buttons, so the tints cannot drift apart. +enum CardStyle { + static func isRefusal(_ option: String) -> Bool { + option.caseInsensitiveCompare("Deny") == .orderedSame + } +} diff --git a/ios/README.md b/ios/README.md index ac35902ed..53b9a294c 100644 --- a/ios/README.md +++ b/ios/README.md @@ -57,8 +57,13 @@ ios/ MausAvatar.swift the mascot face, in the desktop's palette PairingView.swift QR handoff, discovery, address and code fallback PairingScanner.swift native QR camera, permission and recovery UI - ChatListView.swift roster, with "waiting on you" pulled to the top - ChatView.swift transcript, approval cards, composer + Glass.swift the one material the chrome is made of (Liquid Glass on 26+) + SpeechBubble.swift the bubble shape; the tail is the reference vector, scaled + ChatListView.swift roster: glass header, groups strip, bots, the Updates bar + Updates.swift what the Updates pill shows — only bots doing something + UpdatesSheet.swift the pill opened: needs you / working / to review + NewGroupSheet.swift make a room from the phone + ChatView.swift transcript, tailed bubbles, approval cards, composer ComputerView.swift opt-in live view of a bot's computer MarkdownText.swift the supported Markdown presentation layer SettingsView.swift status, and unpair @@ -115,7 +120,7 @@ here by simply not having the methods: | Allowed | Refused | |---|---| | Read bots, rooms and transcripts | Write API keys (`PUT /api/config`) | -| Send messages | Manage pairing or revoke devices | +| Send messages, make a bot or a room | Manage pairing or revoke devices | | **Answer approvals and questions** | Drive the Local VM or this computer | | Interrupt a bot, mark chats read | Reach `/api/internal/*` | | Fetch screen images on demand | Load the packaged desktop UI | diff --git a/ios/Shared/BotActivity.swift b/ios/Shared/BotActivity.swift new file mode 100644 index 000000000..6392371f7 --- /dev/null +++ b/ios/Shared/BotActivity.swift @@ -0,0 +1,63 @@ +// The Live Activity's contract — compiled into both the app and the widget +// extension, so the two agree on what a bot's island says. +// +// One activity per bot that is doing something: needs you, or working. +// Quiet bots have no activity, the same rule as the Updates pill. +import ActivityKit +import AppIntents +import Foundation + +struct BotActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + /// `MausState.rawValue` — the face to wear. + var face: String + /// "needsYou" | "working" | "toReview" + var kind: String + /// "Scout needs you", "Forge is working" + var headline: String + /// The question, or what it is doing. + var line: String + /// A pending card to answer, when kind == needsYou. + var requestId: String? + var options: [String] + /// A permission card answers allow/deny; a question answers with text. + var isPermission: Bool + /// When this kind began — the island's timer and ring count from it. + var since: Date + } + + var botId: String + var threadId: String + var name: String + /// MausPalette colour name. + var color: String +} + +/// Answer from the island or the lock screen. A `LiveActivityIntent` runs in +/// the app's own process, so the app wires `handler` at launch and the +/// widget copy of this type never performs anything itself. +struct AnswerApprovalIntent: LiveActivityIntent { + static var title: LocalizedStringResource = "Answer" + static var isDiscoverable = false + + @Parameter(title: "Thread") var threadId: String + @Parameter(title: "Request") var requestId: String + @Parameter(title: "Choice") var choice: String + @Parameter(title: "Permission") var isPermission: Bool + + init() {} + init(threadId: String, requestId: String, choice: String, isPermission: Bool) { + self.threadId = threadId + self.requestId = requestId + self.choice = choice + self.isPermission = isPermission + } + + /// Set by the app at launch. Nil in the widget extension. + nonisolated(unsafe) static var handler: ((_ threadId: String, _ requestId: String, _ choice: String, _ isPermission: Bool) async -> Void)? + + func perform() async throws -> some IntentResult { + await Self.handler?(threadId, requestId, choice, isPermission) + return .result() + } +} diff --git a/ios/Sources/CompanionCore/Client.swift b/ios/Sources/CompanionCore/Client.swift index 40b7dae75..9044de4a2 100644 --- a/ios/Sources/CompanionCore/Client.swift +++ b/ios/Sources/CompanionCore/Client.swift @@ -32,16 +32,24 @@ public struct Connection: Codable, Hashable, Identifiable, Sendable { /// The representation `URLComponents.host` accepts for a literal IPv6 /// address. It adds brackets exactly once and leaves DNS/IPv4 names alone. - /// A scope zone on a link-local address is intentionally retained; + /// A scope zone on a link-local IPv6 address is intentionally retained; /// URLComponents percent-encodes it when it builds the URL. + /// + /// An interface zone on anything *else* is dropped. `NWEndpoint.Host` + /// describes a resolved IPv4 address with the interface it arrived on + /// ("192.168.1.3%en0"); the zone carries no meaning there and + /// URLComponents refuses it as a host, which made a Bonjour-discovered + /// computer fail with "that address doesn't look right". public static func urlHost(_ host: String) -> String { - let bare: String + var bare: String if host.hasPrefix("["), host.hasSuffix("]") { bare = String(host.dropFirst().dropLast()) } else { bare = host } - return bare.contains(":") ? "[\(bare)]" : bare + if bare.contains(":") { return "[\(bare)]" } + if let zone = bare.firstIndex(of: "%") { bare = String(bare[.. Room { + var body: [String: Any] = ["memberIds": memberIds] + if let name, !name.trimmingCharacters(in: .whitespaces).isEmpty { body["name"] = name } + return try await send(try makeRequest("POST", "/api/groups", body: body), as: CreatedRoom.self).group + } + public func send(text: String, toBot botId: String) async throws { try await send(try makeRequest("POST", "/api/bots/\(botId)/messages", body: ["text": text])) } diff --git a/ios/Sources/CompanionCore/Models.swift b/ios/Sources/CompanionCore/Models.swift index dc726330e..b3e0e49f8 100644 --- a/ios/Sources/CompanionCore/Models.swift +++ b/ios/Sources/CompanionCore/Models.swift @@ -371,6 +371,11 @@ public struct CreatedBot: Codable, Sendable { public var bot: Bot } +/// `POST /api/groups` — the harness answers with the room it made. +public struct CreatedRoom: Codable, Sendable { + public var group: Room +} + struct SearchResponse: Codable, Sendable { var hits: [SearchHit] } diff --git a/ios/TESTING.md b/ios/TESTING.md index cba8a99bb..88b4c61a9 100644 --- a/ios/TESTING.md +++ b/ios/TESTING.md @@ -133,6 +133,23 @@ xcodebuild -project OpenMausCompanion.xcodeproj \ CODE_SIGNING_ALLOWED=NO build ``` +That command is a compile gate only. **To actually pair in the Simulator**, +build with a team and local signing instead — without an +`application-identifier` entitlement the Simulator's keychain refuses the +device token with "A required entitlement isn't present", right after the +code is accepted: + +```sh +xcodebuild -project OpenMausCompanion.xcodeproj -scheme OpenMausCompanion \ + -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + DEVELOPMENT_TEAM= CODE_SIGN_IDENTITY="-" CODE_SIGN_STYLE=Manual build +``` + +No provisioning profile is involved for the Simulator; the team id only +makes Xcode emit the entitlement. Bonjour works from the Simulator (it +shares the Mac's network), so **On this network** lists the Mac — tap it and +type the code; there is no camera for the QR path. + **Re-run `xcodegen generate` whenever a pull adds a file to `App/`.** The generated project lists source files explicitly, so a new one is missing from the target until you regenerate, and the build fails with `Cannot find 'X' in diff --git a/ios/Tests/CompanionCoreTests/ConnectionTests.swift b/ios/Tests/CompanionCoreTests/ConnectionTests.swift index db87b7e43..25dc51613 100644 --- a/ios/Tests/CompanionCoreTests/ConnectionTests.swift +++ b/ios/Tests/CompanionCoreTests/ConnectionTests.swift @@ -30,6 +30,18 @@ final class ConnectionTests: XCTestCase { XCTAssertEqual(connection?.baseURL?.absoluteString, "http://[fe80::1%25en0]:8810") } + func testDropsTheInterfaceScopeFromAResolvedIPv4Address() { + // NWEndpoint.Host prints a resolved IPv4 with the interface it came + // in on ("192.168.1.3%en0"). A zone means nothing for IPv4 and + // URLComponents refuses it as a host — which turned Bonjour discovery + // in the Simulator into "That address doesn't look right". + let discovered = Connection(name: "Mac", host: "192.168.1.3%en0", port: 8810) + XCTAssertEqual(discovered.host, "192.168.1.3") + XCTAssertEqual(discovered.baseURL?.absoluteString, "http://192.168.1.3:8810") + // an IPv6 zone is still kept — link-local needs it + XCTAssertEqual(Connection(name: "Mac", host: "fe80::1%en0", port: 8810).host, "[fe80::1%en0]") + } + func testAnOlderSavedIPv6ConnectionIsNormalizedWhenUsed() throws { let data = Data(#"{"id":"saved","name":"Mac","host":"::1","port":8810}"#.utf8) let saved = try JSONDecoder().decode(Connection.self, from: data) diff --git a/ios/Widgets/Info.plist b/ios/Widgets/Info.plist new file mode 100644 index 000000000..8d74a5b8d --- /dev/null +++ b/ios/Widgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenMausMobile + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/Widgets/OpenMausWidgets.swift b/ios/Widgets/OpenMausWidgets.swift new file mode 100644 index 000000000..44ae26b87 --- /dev/null +++ b/ios/Widgets/OpenMausWidgets.swift @@ -0,0 +1,163 @@ +// The widget extension: the bot's Live Activity, in the Dynamic Island and +// on the lock screen. The face is the mascot engine's resting face for the +// state — the system renders a snapshot, so it cannot move here, but it +// changes with every update. +import ActivityKit +import SwiftUI +import WidgetKit + +@main +struct OpenMausWidgets: WidgetBundle { + var body: some Widget { + BotActivityWidget() + } +} + +struct BotActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: BotActivityAttributes.self) { context in + LockScreenView(context: context) + .activityBackgroundTint(.black) + .activitySystemActionForegroundColor(.white) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + OrbitingFace(context: context, size: 68) + .padding(.leading, 2) + } + DynamicIslandExpandedRegion(.center) { + VStack(alignment: .leading, spacing: 2) { + Text(context.state.headline) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + Text(context.state.line) + .font(.system(size: 13)) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(2) + // the one clock iOS keeps ticking for us + Text(timerInterval: context.state.since...context.state.since.addingTimeInterval(86_400), countsDown: false) + .font(.system(size: 12, weight: .medium).monospacedDigit()) + .foregroundStyle(.white.opacity(0.5)) + } + .padding(.top, 2) + } + DynamicIslandExpandedRegion(.bottom) { + if context.state.kind == "needsYou", let requestId = context.state.requestId, !context.state.options.isEmpty { + AnswerButtons(context: context, requestId: requestId) + .padding(.top, 4) + } + } + } compactLeading: { + MausFaceStill(color: context.attributes.color, state: MausState(rawValue: context.state.face) ?? .idle, size: 24) + } compactTrailing: { + compactTrailing(context) + } minimal: { + MausFaceStill(color: context.attributes.color, state: MausState(rawValue: context.state.face) ?? .idle, size: 22) + } + .keylineTint(MausPalette.color(context.attributes.color)) + } + } + + @ViewBuilder + private func compactTrailing(_ context: ActivityViewContext) -> some View { + switch context.state.kind { + case "needsYou": + Image(systemName: "hand.raised.fill") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(MausPalette.color(context.attributes.color)) + case "working": + Image(systemName: "circle.dotted") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white.opacity(0.8)) + default: + Circle().fill(MausPalette.color(context.attributes.color)).frame(width: 8, height: 8) + } + } +} + +private struct LockScreenView: View { + let context: ActivityViewContext + + var body: some View { + HStack(alignment: .top, spacing: 12) { + OrbitingFace(context: context, size: 60) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + if context.state.kind == "needsYou" { + Image(systemName: "hand.raised.fill") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(MausPalette.color(context.attributes.color)) + } + Text(context.state.headline) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + } + Text(context.state.line) + .font(.system(size: 13)) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(2) + if context.state.kind == "needsYou", let requestId = context.state.requestId, !context.state.options.isEmpty { + AnswerButtons(context: context, requestId: requestId) + .padding(.top, 4) + } + } + Spacer(minLength: 0) + } + .padding(14) + } +} + +/// The card's options, as pills — exactly the options the card offered. +private struct AnswerButtons: View { + let context: ActivityViewContext + let requestId: String + + var body: some View { + HStack(spacing: 8) { + ForEach(context.state.options, id: \.self) { option in + Button(intent: AnswerApprovalIntent( + threadId: context.attributes.threadId, + requestId: requestId, + choice: option, + isPermission: context.state.isPermission + )) { + Text(option) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 34) + .background( + Capsule().fill( + option.caseInsensitiveCompare("Deny") == .orderedSame + ? Color.white.opacity(0.16) + : MausPalette.color(context.attributes.color) + ) + ) + } + .buttonStyle(.plain) + } + } + } +} + +/// The face with comets around it and a rainbow ring filling up behind it. +/// The system renders a snapshot, so the comets are a frame — a different +/// one each update — and the ring is a timer-driven `ProgressView`, which is +/// the one kind of motion iOS keeps animating in a Live Activity on its own. +private struct OrbitingFace: View { + let context: ActivityViewContext + let size: CGFloat + + var body: some View { + ZStack { + ProgressView(timerInterval: context.state.since...context.state.since.addingTimeInterval(60), countsDown: false) { EmptyView() } currentValueLabel: { EmptyView() } + .progressViewStyle(.circular) + .tint(AngularGradient(colors: [ + Color(hex: "#A855F7"), Color(hex: "#38BDF8"), Color(hex: "#34D399"), + Color(hex: "#FACC15"), Color(hex: "#FB923C"), Color(hex: "#F43F5E"), Color(hex: "#A855F7"), + ], center: .center)) + .frame(width: size + 4, height: size + 4) + MausFaceStill(color: context.attributes.color, state: MausState(rawValue: context.state.face) ?? .idle, size: size, comets: true, at: Date()) + } + } +} diff --git a/ios/project.yml b/ios/project.yml index 129bf89f1..c2cca7260 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -24,16 +24,18 @@ targets: platform: iOS sources: - path: App + - path: Shared dependencies: - package: CompanionCore product: CompanionCore + - target: OpenMausCompanionWidgets settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.openmausbot.companion MARKETING_VERSION: "1.0.0" CURRENT_PROJECT_VERSION: "1" SWIFT_VERSION: "5.9" - TARGETED_DEVICE_FAMILY: "1,2" + TARGETED_DEVICE_FAMILY: "1" # The catalog lives in App/, which is already a source path. Generated # by `node scripts/make-app-icon.mjs` from the same mascot the app # draws, so the icon cannot drift from the thing it depicts. @@ -49,6 +51,9 @@ targets: CFBundleVersion: $(CURRENT_PROJECT_VERSION) LSApplicationCategoryType: public.app-category.productivity ITSAppUsesNonExemptEncryption: false + # The bot's Live Activity — its face and ask in the Dynamic Island + # and on the lock screen. + NSSupportsLiveActivities: true CFBundleURLTypes: - CFBundleURLName: com.openmausbot.companion.pairing CFBundleURLSchemes: @@ -95,3 +100,30 @@ targets: ts.net: NSIncludesSubdomains: true NSExceptionAllowsInsecureHTTPLoads: true + + # The Live Activity lives in a widget extension. It shares the mascot's + # drawing and data with the app, and the activity contract in Shared/. + OpenMausCompanionWidgets: + type: app-extension + platform: iOS + sources: + - path: Widgets + - path: Shared + - path: App/MausAvatar.swift + - path: App/MausFaceData.swift + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.openmausbot.companion.widgets + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + SWIFT_VERSION: "5.9" + TARGETED_DEVICE_FAMILY: "1" + SKIP_INSTALL: true + info: + path: Widgets/Info.plist + properties: + CFBundleDisplayName: OpenMausMobile + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension