2026-06-13 02:20:53 -07:00
|
|
|
import SwiftUI
|
|
|
|
|
import NucleicProtocol
|
|
|
|
|
|
2026-07-05 18:08:12 -07:00
|
|
|
/// Start a new chat, revealed as a floating bar when the "+" is tapped (see `NewChatAffordance`).
|
|
|
|
|
/// Styled as the same Liquid Glass composer bar the open session carries, for consistency: picks a
|
|
|
|
|
/// project (or is scoped to one), types a prompt, optionally toggles Auto.
|
2026-06-13 02:20:53 -07:00
|
|
|
struct StartChatComposer: View {
|
|
|
|
|
@EnvironmentObject var store: RemoteStore
|
2026-07-05 18:22:22 -07:00
|
|
|
/// The prompt text, owned by `NewChatAffordance` so it can coordinate auto-focus and the
|
|
|
|
|
/// close-on-empty-dismiss behaviour with the composer.
|
|
|
|
|
@Binding var draft: String
|
|
|
|
|
/// The prompt field's focus, likewise owned by the affordance (auto-focused on open, resigned
|
|
|
|
|
/// to dismiss the keyboard).
|
|
|
|
|
var focus: FocusState<Bool>.Binding
|
|
|
|
|
/// Called after a chat is kicked off, so the affordance can dismiss the composer.
|
2026-07-05 05:18:42 -07:00
|
|
|
var onStart: (() -> Void)? = nil
|
2026-07-05 17:43:18 -07:00
|
|
|
/// When set, the composer is scoped to this project and the picker is hidden — used by a
|
|
|
|
|
/// project page's "+", so the chat always lands in that project.
|
|
|
|
|
var lockedProject: WireProject? = nil
|
2026-06-13 02:20:53 -07:00
|
|
|
@State private var projectID: ProjectID?
|
2026-07-05 19:47:27 -07:00
|
|
|
@State private var autoOverride: Bool?
|
2026-06-21 04:03:53 -07:00
|
|
|
@State private var model: String?
|
2026-06-20 17:02:48 -07:00
|
|
|
@State private var effort = MobileEfforts.fallback
|
2026-07-02 03:43:13 -07:00
|
|
|
// Advanced options (the Mac new-session sheet's base branch + worktree fields).
|
|
|
|
|
@State private var showOptions = false
|
|
|
|
|
@State private var baseBranch = ""
|
|
|
|
|
@State private var useWorktree = true
|
2026-06-13 02:20:53 -07:00
|
|
|
|
|
|
|
|
private var projects: [WireProject] { store.dashboard.projects }
|
2026-07-06 00:40:31 -07:00
|
|
|
/// The picker defaults to "No project" (nil) rather than auto-selecting the first project, so a
|
|
|
|
|
/// typed prompt never quietly starts an agent in a repo the user didn't choose — they pick one
|
|
|
|
|
/// explicitly before the send button lights. A project page's composer stays scoped via
|
|
|
|
|
/// `lockedProject`.
|
2026-06-13 02:20:53 -07:00
|
|
|
private var selected: WireProject? {
|
2026-07-06 00:40:31 -07:00
|
|
|
lockedProject ?? projects.first { $0.id == projectID }
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
2026-06-21 04:03:53 -07:00
|
|
|
private var controlled: Bool { selected?.isNucleicControlled ?? false }
|
2026-07-05 19:47:27 -07:00
|
|
|
/// Auto-approve for the chat about to start: the user's choice off-control, but forced on for a
|
|
|
|
|
/// Nucleic Control project. The host locks Auto on for those chats regardless of what the phone
|
|
|
|
|
/// sends (they run autonomously — nvrsion needs it: see `AppStore.createSession`), so the
|
|
|
|
|
/// composer shows it locked on to match the Mac rather than offering a toggle that wouldn't take.
|
|
|
|
|
private var effectiveAuto: Bool { controlled || (autoOverride ?? false) }
|
2026-06-13 02:20:53 -07:00
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
HStack {
|
2026-07-05 17:43:18 -07:00
|
|
|
if lockedProject == nil {
|
|
|
|
|
Menu {
|
2026-07-06 00:40:36 -07:00
|
|
|
Button("No project") { projectID = nil }
|
|
|
|
|
if !projects.isEmpty { Divider() }
|
2026-07-05 17:43:18 -07:00
|
|
|
ForEach(projects) { project in
|
|
|
|
|
Button(project.name) { projectID = project.id }
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
|
|
|
|
// The model also selects the backend, so the picker offers every project.
|
2026-07-06 00:40:36 -07:00
|
|
|
// Defaults to "No project" until one is chosen.
|
|
|
|
|
Label(selected?.name ?? "No project", systemImage: "folder")
|
2026-07-05 17:43:18 -07:00
|
|
|
.font(.subheadline)
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
2026-07-05 17:43:18 -07:00
|
|
|
} else {
|
|
|
|
|
// Scoped to this project's page — show it as context, not a picker.
|
|
|
|
|
Label(selected?.name ?? "", systemImage: "folder")
|
|
|
|
|
.font(.subheadline).foregroundStyle(.secondary)
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
|
|
|
|
Spacer()
|
2026-07-05 19:47:27 -07:00
|
|
|
Toggle(isOn: Binding(get: { effectiveAuto }, set: { autoOverride = $0 })) {
|
|
|
|
|
Label("Auto", systemImage: effectiveAuto ? "bolt.fill" : "bolt.slash")
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
|
|
|
|
.toggleStyle(.button)
|
|
|
|
|
.tint(Palette.accent)
|
|
|
|
|
.font(.caption)
|
2026-07-05 19:47:27 -07:00
|
|
|
// Locked on for a Nucleic Control project — those chats always run autonomously.
|
|
|
|
|
.disabled(controlled)
|
|
|
|
|
.help(controlled
|
|
|
|
|
? "Auto-approve is always on for Nucleic Control chats — they run autonomously."
|
|
|
|
|
: "Auto-approve safe actions; destructive ones still ask.")
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
2026-06-21 04:03:53 -07:00
|
|
|
HStack {
|
|
|
|
|
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
|
|
|
|
|
Spacer()
|
|
|
|
|
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled)
|
2026-07-02 03:43:13 -07:00
|
|
|
Button {
|
|
|
|
|
withAnimation(.easeInOut(duration: 0.18)) { showOptions.toggle() }
|
|
|
|
|
} label: {
|
|
|
|
|
Image(systemName: showOptions ? "chevron.up.circle" : "ellipsis.circle")
|
|
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(Palette.accent)
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
}
|
|
|
|
|
if showOptions {
|
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
Image(systemName: "arrow.triangle.branch").foregroundStyle(.secondary)
|
|
|
|
|
TextField(selected?.defaultBranch ?? "Base branch", text: $baseBranch)
|
|
|
|
|
.textInputAutocapitalization(.never)
|
|
|
|
|
.autocorrectionDisabled()
|
|
|
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
|
.font(.caption)
|
|
|
|
|
}
|
|
|
|
|
Toggle(isOn: $useWorktree) {
|
|
|
|
|
Label("Run in a worktree", systemImage: "square.stack.3d.up")
|
|
|
|
|
.font(.caption)
|
|
|
|
|
}
|
|
|
|
|
.tint(Palette.accent)
|
|
|
|
|
}
|
|
|
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
2026-06-21 04:03:53 -07:00
|
|
|
}
|
2026-06-13 02:20:53 -07:00
|
|
|
HStack(alignment: .bottom, spacing: 8) {
|
2026-07-05 17:55:08 -07:00
|
|
|
// Plain, borderless field — matching the open session's chat bar rather than a
|
|
|
|
|
// boxed form control.
|
2026-06-13 02:20:53 -07:00
|
|
|
TextField("Describe a task…", text: $draft, axis: .vertical)
|
2026-07-05 17:43:18 -07:00
|
|
|
.textFieldStyle(.plain)
|
2026-06-13 02:20:53 -07:00
|
|
|
.lineLimit(1...5)
|
2026-07-05 17:43:18 -07:00
|
|
|
.padding(.vertical, 3)
|
2026-06-21 04:03:53 -07:00
|
|
|
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled)
|
2026-07-05 18:22:28 -07:00
|
|
|
.focused(focus)
|
2026-06-13 02:20:53 -07:00
|
|
|
Button {
|
|
|
|
|
if let project = selected {
|
2026-07-02 03:43:13 -07:00
|
|
|
let branch = baseBranch.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
store.startChat(
|
|
|
|
|
in: project.id, message: draft, model: model, effort: effort,
|
|
|
|
|
baseBranch: branch.isEmpty ? nil : branch,
|
2026-07-05 19:47:27 -07:00
|
|
|
useWorktree: useWorktree, auto: effectiveAuto)
|
2026-06-13 02:20:53 -07:00
|
|
|
draft = ""
|
2026-07-05 05:18:42 -07:00
|
|
|
onStart?()
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
|
|
|
|
} label: {
|
|
|
|
|
Image(systemName: "arrow.up.circle.fill").font(.title)
|
|
|
|
|
}
|
|
|
|
|
.disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
|
2026-07-04 00:48:56 -07:00
|
|
|
.keyboardShortcut(.return, modifiers: .command)
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
|
|
|
|
if !store.canControl {
|
|
|
|
|
Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-05 18:08:12 -07:00
|
|
|
// The same Liquid Glass bar the open session carries, so the new-chat entry box is visually
|
|
|
|
|
// identical to the in-chat composer.
|
|
|
|
|
.padding(.horizontal, 14)
|
|
|
|
|
.padding(.vertical, 12)
|
|
|
|
|
.glassSurface(cornerRadius: 26)
|
2026-07-05 19:47:27 -07:00
|
|
|
// Prefill the base branch with the project's default so it actually appears (mirroring the
|
|
|
|
|
// Mac composer's `effectiveBranch`), rather than sitting as an empty placeholder.
|
|
|
|
|
.onAppear(perform: syncToProject)
|
|
|
|
|
.onChange(of: projectID) { _, _ in syncToProject() }
|
2026-06-21 04:03:53 -07:00
|
|
|
// Switching to a model with a lower effort cap can't leave an unsupported level selected.
|
|
|
|
|
.onChange(of: model) { _, newModel in
|
|
|
|
|
let levels = store.modelCatalog.offeredEfforts(forModel: newModel)
|
|
|
|
|
let sentinel = store.modelCatalog.orchestraSentinelOrFallback
|
|
|
|
|
if effort.caseInsensitiveCompare(sentinel) != .orderedSame, !levels.contains(effort) {
|
|
|
|
|
effort = levels.last ?? MobileEfforts.fallback
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-04 01:18:35 -07:00
|
|
|
}
|
2026-07-05 19:47:27 -07:00
|
|
|
|
|
|
|
|
/// Sync the composer to the freshly-selected project: show its default branch as the base, and
|
|
|
|
|
/// resolve a carried-over Orchestra selection down to a real level when the new project isn't
|
|
|
|
|
/// under Nucleic Control (Orchestra can't run there — mirrors the Mac's `effectiveEffort`).
|
|
|
|
|
private func syncToProject() {
|
|
|
|
|
baseBranch = selected?.defaultBranch ?? ""
|
|
|
|
|
if MobileEfforts.isOrchestra(effort), !controlled {
|
|
|
|
|
effort = store.modelCatalog.offeredEfforts(forModel: model).last ?? MobileEfforts.fallback
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-05 17:43:18 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-05 18:08:12 -07:00
|
|
|
/// The floating "+" start-chat affordance: a Liquid Glass circle in the lower-right that reveals
|
|
|
|
|
/// the glass `StartChatComposer` as a floating bar over the page — the same entry box the open
|
|
|
|
|
/// session carries, not a full modal pane. Shared by Home, Sessions, and a project page (which
|
|
|
|
|
/// scopes it to `project`), so all three offer the same control.
|
2026-07-05 17:43:18 -07:00
|
|
|
struct NewChatAffordance: ViewModifier {
|
|
|
|
|
@EnvironmentObject var store: RemoteStore
|
|
|
|
|
/// Scopes the composer to one project (project page); nil offers the picker (Home, Sessions).
|
|
|
|
|
var project: WireProject? = nil
|
2026-07-05 18:47:58 -07:00
|
|
|
/// Owned by the caller so it can also open the composer programmatically (iPad ⌘N on Home,
|
|
|
|
|
/// the compact shell's shared tab-bar "+").
|
2026-07-05 17:43:18 -07:00
|
|
|
@Binding var isPresented: Bool
|
2026-07-05 18:47:58 -07:00
|
|
|
/// Whether to draw the floating "+" disc. The compact shell hosts its own "+" beside the tab
|
|
|
|
|
/// bar and only wants the composer presentation, so it passes `false`; per-page callers (iPad)
|
|
|
|
|
/// keep their own disc.
|
|
|
|
|
var showsButton: Bool = true
|
2026-07-05 18:23:10 -07:00
|
|
|
/// Prompt text and its focus live here so the scrim tap and auto-focus can coordinate with the
|
|
|
|
|
/// composer (whether it's empty, whether the keyboard is up).
|
|
|
|
|
@State private var draft = ""
|
|
|
|
|
@FocusState private var draftFocused: Bool
|
2026-07-05 17:43:18 -07:00
|
|
|
|
|
|
|
|
/// The button only makes sense when this device can control a Mac and there's a project to
|
|
|
|
|
/// start a chat in; otherwise the composer it opens would be inert.
|
|
|
|
|
private var canStart: Bool {
|
|
|
|
|
store.canControl && (project != nil || !store.dashboard.projects.isEmpty)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 18:23:10 -07:00
|
|
|
private var isEmpty: Bool { draft.trimmingCharacters(in: .whitespaces).isEmpty }
|
|
|
|
|
|
2026-07-05 17:43:18 -07:00
|
|
|
func body(content: Content) -> some View {
|
|
|
|
|
content
|
2026-07-05 18:08:12 -07:00
|
|
|
// The "+" disc — hidden while the composer is up so it doesn't sit over the bar.
|
2026-07-05 17:43:18 -07:00
|
|
|
.overlay(alignment: .bottomTrailing) {
|
2026-07-05 18:48:06 -07:00
|
|
|
if showsButton && canStart && !isPresented {
|
2026-07-05 17:43:18 -07:00
|
|
|
Button { isPresented = true } label: {
|
|
|
|
|
Image(systemName: "plus")
|
|
|
|
|
.font(.system(size: 22, weight: .semibold))
|
|
|
|
|
.foregroundStyle(Palette.accent)
|
|
|
|
|
.frame(width: 56, height: 56)
|
|
|
|
|
// The whole disc is the hit target — without this, taps on the
|
|
|
|
|
// translucent glass outside the glyph fall through to the list row
|
|
|
|
|
// behind the button.
|
|
|
|
|
.contentShape(Circle())
|
|
|
|
|
.glassCircle()
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
.padding(.trailing, 20)
|
|
|
|
|
.padding(.bottom, 6)
|
2026-07-05 18:08:12 -07:00
|
|
|
.transition(.scale.combined(with: .opacity))
|
2026-07-05 17:43:18 -07:00
|
|
|
.accessibilityLabel("New chat")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-05 18:08:12 -07:00
|
|
|
// The composer itself, floating up from the bottom over a light scrim — no modal pane,
|
2026-07-05 18:23:10 -07:00
|
|
|
// no nav chrome, just the entry box.
|
2026-07-05 18:08:12 -07:00
|
|
|
.overlay {
|
|
|
|
|
if isPresented {
|
|
|
|
|
ZStack(alignment: .bottom) {
|
|
|
|
|
Rectangle()
|
|
|
|
|
.fill(.black.opacity(0.2))
|
|
|
|
|
.ignoresSafeArea()
|
|
|
|
|
.transition(.opacity)
|
2026-07-05 18:23:10 -07:00
|
|
|
// Tapping away drops the keyboard; with nothing typed there's nothing to
|
2026-07-05 18:24:11 -07:00
|
|
|
// keep the bar around for, so close it too. A draft in progress stays
|
|
|
|
|
// open (keyboard down) so a stray tap doesn't discard it — a second tap
|
|
|
|
|
// (keyboard already down) then closes it.
|
2026-07-05 18:23:10 -07:00
|
|
|
.onTapGesture {
|
2026-07-05 18:24:11 -07:00
|
|
|
if draftFocused {
|
|
|
|
|
draftFocused = false
|
|
|
|
|
if isEmpty { isPresented = false }
|
|
|
|
|
} else {
|
|
|
|
|
isPresented = false
|
|
|
|
|
}
|
2026-07-05 18:23:10 -07:00
|
|
|
}
|
|
|
|
|
StartChatComposer(draft: $draft, focus: $draftFocused,
|
|
|
|
|
onStart: { isPresented = false }, lockedProject: project)
|
2026-07-05 19:07:55 -07:00
|
|
|
// Taps on the bar's own empty spots (rounded corners, gaps between
|
|
|
|
|
// controls) must not fall through to the scrim and close it — only taps
|
|
|
|
|
// outside the bar dismiss. A clear tap-catcher sized to the bar absorbs
|
|
|
|
|
// them; the controls, in front, still handle their own taps.
|
|
|
|
|
.background(Color.clear.contentShape(Rectangle()).onTapGesture {})
|
2026-07-05 18:08:12 -07:00
|
|
|
.padding(.horizontal, 12)
|
|
|
|
|
.padding(.bottom, 8)
|
|
|
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
2026-07-05 19:03:26 -07:00
|
|
|
// A deliberate swipe down dismisses the bar (drops the keyboard first),
|
|
|
|
|
// the gestural twin of tapping the scrim. minimumDistance keeps taps on
|
|
|
|
|
// the field / menus / send button working; the vertical-dominant check
|
|
|
|
|
// ignores horizontal drags (e.g. moving the text cursor).
|
|
|
|
|
.gesture(
|
|
|
|
|
DragGesture(minimumDistance: 20)
|
|
|
|
|
.onEnded { value in
|
|
|
|
|
if value.translation.height > 60,
|
|
|
|
|
value.translation.height > abs(value.translation.width) {
|
|
|
|
|
draftFocused = false
|
|
|
|
|
isPresented = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-07-05 18:08:12 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-05 17:43:18 -07:00
|
|
|
}
|
2026-07-05 18:08:12 -07:00
|
|
|
.animation(.spring(response: 0.35, dampingFraction: 0.86), value: isPresented)
|
2026-07-05 18:23:10 -07:00
|
|
|
.onChange(of: isPresented) { _, open in
|
|
|
|
|
if open {
|
|
|
|
|
draft = ""
|
2026-07-05 19:45:15 -07:00
|
|
|
// Focus in the same turn the bar is presented so the keyboard rises immediately,
|
|
|
|
|
// riding up together with the bar (both animate from the bottom edge at t=0)
|
|
|
|
|
// instead of waiting for the spring to settle first — no perceptible delay
|
|
|
|
|
// between the composer appearing and the keyboard opening.
|
|
|
|
|
draftFocused = true
|
2026-07-05 18:23:10 -07:00
|
|
|
} else {
|
|
|
|
|
draftFocused = false
|
|
|
|
|
draft = ""
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-05 17:43:18 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-04 01:18:35 -07:00
|
|
|
|
2026-07-05 17:43:18 -07:00
|
|
|
extension View {
|
|
|
|
|
/// Add the floating "+" start-chat affordance (glass circle → glass composer sheet). See
|
|
|
|
|
/// `NewChatAffordance`.
|
2026-07-05 18:50:07 -07:00
|
|
|
/// `enabled: false` drops the affordance entirely — the compact shell hosts a single shared "+"
|
|
|
|
|
/// beside its tab bar, so the per-page discs (Home, a project page) apply this only at regular
|
|
|
|
|
/// width, where there's no tab bar to carry it.
|
|
|
|
|
@ViewBuilder
|
2026-07-05 18:48:11 -07:00
|
|
|
func newChatAffordance(project: WireProject? = nil, isPresented: Binding<Bool>,
|
2026-07-05 18:50:07 -07:00
|
|
|
showsButton: Bool = true, enabled: Bool = true) -> some View {
|
|
|
|
|
if enabled {
|
|
|
|
|
modifier(NewChatAffordance(project: project, isPresented: isPresented, showsButton: showsButton))
|
|
|
|
|
} else {
|
|
|
|
|
self
|
|
|
|
|
}
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A compact peek at open ideas with inline capture; the full inbox is the To-Dos tab.
|
|
|
|
|
struct QuickTodos: View {
|
|
|
|
|
@EnvironmentObject var store: RemoteStore
|
|
|
|
|
@State private var draft = ""
|
|
|
|
|
|
|
|
|
|
private var open: [WireTodo] { store.dashboard.todos.filter { $0.status == .open } }
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
HStack {
|
|
|
|
|
Text("To-dos").font(.headline)
|
|
|
|
|
Spacer()
|
|
|
|
|
if !open.isEmpty { Text("\(open.count)").foregroundStyle(.secondary) }
|
|
|
|
|
}
|
|
|
|
|
HStack {
|
|
|
|
|
TextField("Capture an idea…", text: $draft)
|
|
|
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
|
.onSubmit(capture)
|
|
|
|
|
Button(action: capture) { Image(systemName: "plus.circle.fill").font(.title3) }
|
|
|
|
|
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
|
|
|
|
|
}
|
|
|
|
|
ForEach(open.prefix(3)) { todo in
|
|
|
|
|
HStack(spacing: 10) {
|
|
|
|
|
Button { store.completeTodo(todo.id) } label: {
|
|
|
|
|
Image(systemName: "circle").foregroundStyle(.secondary)
|
|
|
|
|
}.buttonStyle(.plain)
|
|
|
|
|
Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text)
|
|
|
|
|
.font(.subheadline).lineLimit(1)
|
2026-07-02 04:20:25 -07:00
|
|
|
if let triage = todo.triage { TriageBadge(rawLevel: triage) }
|
2026-06-13 02:20:53 -07:00
|
|
|
Spacer()
|
|
|
|
|
if let name = todo.projectName {
|
|
|
|
|
Text(name).font(.caption2).foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.card()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func capture() {
|
|
|
|
|
store.captureTodo(draft, projectID: nil)
|
|
|
|
|
draft = ""
|
|
|
|
|
}
|
|
|
|
|
}
|