From cc01ce900f23ae99180bfbfb4086f04a42297a55 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 7 Jul 2026 06:07:58 +0200 Subject: [PATCH] feat: rebuild the reply composer Gmail-style - borderless full-width From/To/Cc/Subject rows separated only by hairlines, the draft editor fills the whole sheet, send moves to a toolbar paperplane with a when-to-send menu (now/smart/schedule with a graphical date sheet and an inline schedule banner), and AI/templates sit in a quiet helper bar above the keyboard --- .../Features/Unibox/UniboxComposer.swift | 415 ++++++++++-------- 1 file changed, 231 insertions(+), 184 deletions(-) diff --git a/ios/Warmbly/Features/Unibox/UniboxComposer.swift b/ios/Warmbly/Features/Unibox/UniboxComposer.swift index d965e2aa3..81a4e5a50 100644 --- a/ios/Warmbly/Features/Unibox/UniboxComposer.swift +++ b/ios/Warmbly/Features/Unibox/UniboxComposer.swift @@ -23,6 +23,7 @@ struct UniboxComposer: View { @State private var scheduledAt = Date().addingTimeInterval(3600) @State private var showAIWriter = false @State private var showTemplates = false + @State private var showSchedulePicker = false init(context: UniboxComposeContext, openAIOnAppear: Bool = false, onSent: @escaping (UniboxSendResponse) -> Void) { self.context = context @@ -52,36 +53,68 @@ struct UniboxComposer: View { var body: some View { NavigationStack { - ScrollView { - VStack(spacing: 14) { - addressCard - messageCard - sendOptionsCard - if let error = store.errorMessage { - Text(error) - .font(.subheadline) - .foregroundStyle(WTheme.negative) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 4) + VStack(spacing: 0) { + labeledRow("From") { + Text(context.accountEmail ?? "This mailbox") + .font(.subheadline) + .lineLimit(1) + } + hairline + labeledRow("To") { + TextField("", text: $to) + .font(.subheadline) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + if !showCC { + Button("Cc") { + withAnimation(.snappy) { showCC = true } + } + .font(.subheadline) + .foregroundStyle(.secondary) } } - .padding(16) - .padding(.bottom, 8) + hairline + if showCC { + labeledRow("Cc") { + TextField("", text: $cc) + .font(.subheadline) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + } + hairline + } + TextField("Subject", text: $subject) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 16) + .frame(minHeight: 46) + hairline + if sendMode != .instant { + scheduleBanner + hairline + } + if let error = store.errorMessage { + Text(error) + .font(.footnote) + .foregroundStyle(WTheme.negative) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 8) + hairline + } + editor } - .background(Color(.systemGroupedBackground)) - .scrollDismissesKeyboard(.interactively) + .background(Color(.systemBackground)) .navigationTitle("Reply") .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - } - .safeAreaInset(edge: .bottom) { sendButton } + .toolbar { toolbarContent } + .safeAreaInset(edge: .bottom) { helperBar } } .presentationDetents([.large]) .presentationDragIndicator(.visible) .interactiveDismissDisabled(store.isSending) + .sheet(isPresented: $showSchedulePicker) { scheduleSheet } .sheet(isPresented: $showAIWriter) { UniboxAIWriterSheet(replySubject: subject) { text in insert(text) @@ -105,20 +138,161 @@ struct UniboxComposer: View { } } - // MARK: Assist row + // MARK: Toolbar - private var assistRow: some View { + /// Gmail-style chrome: X to close, paperplane to send, and a menu holding + /// the when-to-send choice plus the helpers. + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("Close") + } + ToolbarItemGroup(placement: .topBarTrailing) { + if store.isSending { + ProgressView() + } else { + Button { + Task { await send() } + } label: { + Image(systemName: sendMode == .scheduled ? "clock.badge.checkmark" : "paperplane.fill") + } + .disabled(!canSend) + .accessibilityLabel(sendLabel) + } + Menu { + Picker("When to send", selection: $sendMode) { + Label("Send now", systemImage: "paperplane").tag(UniboxSendMode.instant) + Label("Smart send", systemImage: "wand.and.stars").tag(UniboxSendMode.smart) + if sendMode == .scheduled { + Label("Scheduled", systemImage: "clock").tag(UniboxSendMode.scheduled) + } + } + Button { + showSchedulePicker = true + } label: { + Label( + sendMode == .scheduled ? "Change send time" : "Schedule send", + systemImage: "clock" + ) + } + if canTemplates { + Button { + showTemplates = true + } label: { + Label("Templates", systemImage: "doc.on.doc") + } + } + if canAI { + Button { + showAIWriter = true + } label: { + Label("Write with AI", systemImage: "sparkles") + } + } + } label: { + Image(systemName: "ellipsis") + } + .sensoryFeedback(.selection, trigger: sendMode) + } + } + + // MARK: Field rows + + private func labeledRow(_ label: String, @ViewBuilder value: () -> some View) -> some View { + HStack(spacing: 12) { + Text(label) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(width: 44, alignment: .leading) + value() + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: 46) + } + + private var hairline: some View { + Divider() + } + + /// Gmail-style schedule strip under the fields: names the chosen timing, + /// tap to change it, x to just send now. + private var scheduleBanner: some View { HStack(spacing: 8) { + Image(systemName: sendMode == .scheduled ? "clock" : "wand.and.stars") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(WTheme.accent) + Text( + sendMode == .scheduled + ? "Scheduled for \(scheduledAt.formatted(date: .abbreviated, time: .shortened))" + : "Smart send: goes out at the next safe slot for this mailbox" + ) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: 8) + Button { + withAnimation(.snappy) { sendMode = .instant } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 15)) + .foregroundStyle(.tertiary) + } + .accessibilityLabel("Send now instead") + } + .padding(.horizontal, 16) + .frame(minHeight: 40) + .contentShape(Rectangle()) + .onTapGesture { + if sendMode == .scheduled { showSchedulePicker = true } + } + } + + // MARK: Editor + + /// The draft takes all remaining space, borderless, aligned with the rows. + private var editor: some View { + TextEditor(text: $messageBody) + .font(.body) + .scrollContentBackground(.hidden) + .padding(.horizontal, 11) + .padding(.top, 6) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .overlay(alignment: .topLeading) { + if messageBody.isEmpty { + Text("Compose email") + .font(.body) + .foregroundStyle(.tertiary) + .padding(.top, 14) + .padding(.leading, 16) + .allowsHitTesting(false) + } + } + .onChange(of: messageBody) { + if messageBody.count > UniboxComposerStore.bodyLimit { + messageBody = String(messageBody.prefix(UniboxComposerStore.bodyLimit)) + } + } + } + + // MARK: Helper bar + + /// Quiet helpers above the keyboard: AI, templates, character count. + @ViewBuilder + private var helperBar: some View { + HStack(spacing: 18) { if canAI { Button { showAIWriter = true } label: { Label("Write with AI", systemImage: "sparkles") - .font(.subheadline.weight(.semibold)) + .font(.footnote.weight(.semibold)) .foregroundStyle(Tone.indigo.color) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(Tone.indigo.background, in: Capsule()) } .buttonStyle(TapScaleStyle()) } @@ -127,182 +301,55 @@ struct UniboxComposer: View { showTemplates = true } label: { Label("Templates", systemImage: "doc.on.doc") - .font(.subheadline.weight(.semibold)) + .font(.footnote.weight(.semibold)) .foregroundStyle(WTheme.accent) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(Tone.sky.background, in: Capsule()) } .buttonStyle(TapScaleStyle()) } - Spacer() - } - .listRowSeparator(.hidden) - } - - // MARK: Cards - - /// Settings-style addressing card: labeled hairline rows in one surface. - private var addressCard: some View { - VStack(spacing: 0) { - addressRow("From") { - HStack(spacing: 8) { - WAvatar(name: context.accountEmail ?? "M", seed: context.accountID, size: 24) - Text(context.accountEmail ?? "This mailbox") - .font(.subheadline.weight(.medium)) - .lineLimit(1) - Spacer(minLength: 0) - } - } - cardDivider - addressRow("To") { - TextField("name@example.com", text: $to) - .font(.subheadline) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.emailAddress) - } - if showCC { - cardDivider - addressRow("Cc") { - TextField("name@example.com", text: $cc) - .font(.subheadline) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.emailAddress) - } - } - cardDivider - addressRow("Subject") { - TextField("Subject", text: $subject) - .font(.subheadline) - } - } - .airCard(padding: 0) - .overlay(alignment: .topTrailing) { - if !showCC { - Button("Cc") { - withAnimation(.snappy) { showCC = true } - } - .font(.footnote.weight(.semibold)) - .foregroundStyle(WTheme.accent) - .padding(.top, 13) - .padding(.trailing, 16) - } - } - } - - private func addressRow(_ label: String, @ViewBuilder value: () -> some View) -> some View { - HStack(spacing: 12) { - Text(label) - .font(.subheadline) - .foregroundStyle(.secondary) - .frame(width: 56, alignment: .leading) - value() + Spacer(minLength: 0) + Text("\(messageBody.count) / \(UniboxComposerStore.bodyLimit)") + .font(.caption2) + .monospacedDigit() + .foregroundStyle(.tertiary) } .padding(.horizontal, 16) - .frame(minHeight: 46) + .padding(.vertical, 10) + .background(.bar) + .overlay(alignment: .top) { Divider() } } - private var cardDivider: some View { - Divider().padding(.leading, 84) - } + // MARK: Schedule sheet - /// Message card: assist chips + the draft editor in one surface. - private var messageCard: some View { - VStack(alignment: .leading, spacing: 10) { - if canAI || canTemplates { - assistRow - } - TextEditor(text: $messageBody) - .frame(minHeight: 180) - .font(.body) - .scrollContentBackground(.hidden) - .overlay(alignment: .topLeading) { - if messageBody.isEmpty { - Text("Write a reply") - .font(.body) - .foregroundStyle(.tertiary) - .padding(.top, 8) - .padding(.leading, 5) - .allowsHitTesting(false) - } - } - .onChange(of: messageBody) { - if messageBody.count > UniboxComposerStore.bodyLimit { - messageBody = String(messageBody.prefix(UniboxComposerStore.bodyLimit)) - } - } - HStack { - Spacer() - Text("\(messageBody.count) / \(UniboxComposerStore.bodyLimit)") - .font(.caption) - .monospacedDigit() - .foregroundStyle(.tertiary) - } - } - .airCard() - } - - /// Send timing card: segmented mode + the schedule picker when relevant. - private var sendOptionsCard: some View { - VStack(alignment: .leading, spacing: 12) { - EyebrowLabel("When to send") - Picker("When", selection: $sendMode) { - ForEach(UniboxSendMode.allCases) { mode in - Text(mode.label).tag(mode) - } - } - .pickerStyle(.segmented) - .sensoryFeedback(.selection, trigger: sendMode) - if sendMode == .scheduled { + private var scheduleSheet: some View { + NavigationStack { + VStack(spacing: 0) { DatePicker( "Send at", selection: $scheduledAt, in: scheduleBounds, displayedComponents: [.date, .hourAndMinute] ) - .font(.subheadline) - } else if sendMode == .smart { - Text("Sends at the next safe slot for this mailbox.") - .font(.footnote) - .foregroundStyle(.secondary) + .datePickerStyle(.graphical) + .padding(.horizontal, 12) + Spacer(minLength: 0) } - } - .airCard() - } - - /// The auth-flow CTA carried into the composer: one full-width gradient - /// capsule that names what will happen. - private var sendButton: some View { - Button { - Task { await send() } - } label: { - HStack(spacing: 8) { - if store.isSending { - ProgressView().tint(.white) - Text("Sending") - } else { - Image(systemName: sendMode == .scheduled ? "clock.badge.checkmark" : "paperplane.fill") - .font(.system(size: 15, weight: .semibold)) - Text(sendLabel) + .navigationTitle("Schedule send") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showSchedulePicker = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Set") { + withAnimation(.snappy) { sendMode = .scheduled } + showSchedulePicker = false + } + .fontWeight(.semibold) } } - .font(.body.weight(.semibold)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .frame(height: 50) - .background( - canSend ? AnyShapeStyle(WTheme.accent.gradient) : AnyShapeStyle(Color(.systemGray3)), - in: Capsule() - ) } - .buttonStyle(TapScaleStyle()) - .disabled(!canSend) - .padding(.horizontal, 16) - .padding(.top, 8) - .padding(.bottom, 6) - .background(.bar) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) } // MARK: Labels + bounds