diff --git a/MiniFlow_iOS/MiniFlow-iOS-Info.plist b/MiniFlow_iOS/MiniFlow-iOS-Info.plist
new file mode 100644
index 0000000..041e54f
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow-iOS-Info.plist
@@ -0,0 +1,22 @@
+
+
+
+
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLSchemes
+
+ miniflow
+
+
+
+ UIBackgroundModes
+
+ audio
+ processing
+
+
+
diff --git a/MiniFlow_iOS/MiniFlowKeyboard/KeyboardViewController.swift b/MiniFlow_iOS/MiniFlowKeyboard/KeyboardViewController.swift
new file mode 100644
index 0000000..f074caa
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlowKeyboard/KeyboardViewController.swift
@@ -0,0 +1,395 @@
+import UIKit
+import SwiftUI
+import Combine
+
+class KeyboardViewController: UIInputViewController {
+ private var hostingController: UIHostingController?
+ private var viewModel: KeyboardViewModel?
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .clear
+ view.isOpaque = false
+
+ let vm = KeyboardViewModel(textDocumentProxy: textDocumentProxy, inputViewController: self)
+ self.viewModel = vm
+
+ let voiceInputView = VoiceInputView(
+ viewModel: vm,
+ onSwitchKeyboard: { [weak self] in
+ self?.advanceToNextInputMode()
+ }
+ )
+
+ let hostingController = UIHostingController(rootView: voiceInputView)
+ hostingController.view.translatesAutoresizingMaskIntoConstraints = false
+ hostingController.view.backgroundColor = .clear
+ hostingController.view.isOpaque = false
+
+ addChild(hostingController)
+ view.addSubview(hostingController.view)
+ hostingController.didMove(toParent: self)
+
+ NSLayoutConstraint.activate([
+ hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
+ hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ hostingController.view.heightAnchor.constraint(equalToConstant: 258),
+ ])
+
+ self.hostingController = hostingController
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ viewModel?.refreshSessionState()
+ }
+}
+
+// MARK: - FlowSessionManager (duplicated for extension sandbox)
+
+private enum FlowSessionKeys {
+ static let isSessionActive = "flow_session_active"
+ static let sessionHeartbeat = "flow_session_heartbeat"
+ static let recordingCommand = "flow_recording_command"
+ static let transcriptionResult = "flow_transcription_result"
+ static let recordingStatus = "flow_recording_status"
+ static let errorMessage = "flow_error_message"
+ static let partialTranscript = "flow_partial_transcript"
+}
+
+/// File-based IPC — mirrors the main app's FlowSessionManager.
+private class FlowSessionManager {
+ static let shared = FlowSessionManager()
+
+ private let appGroupID = "group.com.smallestai.MiniFlow"
+ private var containerURL: URL?
+
+ private init() {
+ containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
+ }
+
+ private func read(_ key: String) -> String? {
+ guard let url = containerURL?.appendingPathComponent(key) else { return nil }
+ return try? String(contentsOf: url, encoding: .utf8)
+ }
+
+ private func write(_ value: String, key: String) {
+ guard let url = containerURL?.appendingPathComponent(key) else { return }
+ try? value.write(to: url, atomically: true, encoding: .utf8)
+ }
+
+ var isSessionActive: Bool {
+ guard read(FlowSessionKeys.isSessionActive) == "true" else { return false }
+ // Check heartbeat freshness
+ guard let raw = read(FlowSessionKeys.sessionHeartbeat),
+ let heartbeat = Double(raw) else { return false }
+ let age = Date().timeIntervalSince1970 - heartbeat
+ return age <= 1.5
+ }
+
+ var recordingStatus: String {
+ read(FlowSessionKeys.recordingStatus) ?? "idle"
+ }
+
+ var transcriptionResult: String {
+ get { read(FlowSessionKeys.transcriptionResult) ?? "" }
+ set { write(newValue, key: FlowSessionKeys.transcriptionResult) }
+ }
+
+ var errorMessage: String {
+ read(FlowSessionKeys.errorMessage) ?? ""
+ }
+
+ var partialTranscript: String {
+ read(FlowSessionKeys.partialTranscript) ?? ""
+ }
+
+ func setReturnAppBundleID(_ bundleID: String) {
+ write(bundleID, key: "flow_return_app_bundle_id")
+ }
+
+ func requestStartRecording() {
+ write("start", key: FlowSessionKeys.recordingCommand)
+ }
+
+ func requestStopRecording() {
+ write("stop", key: FlowSessionKeys.recordingCommand)
+ }
+
+ func requestCancelRecording() {
+ write("cancel", key: FlowSessionKeys.recordingCommand)
+ }
+
+ func consumeTranscriptionResult() -> String {
+ let result = transcriptionResult
+ transcriptionResult = ""
+ write("idle", key: FlowSessionKeys.recordingStatus)
+ write("", key: FlowSessionKeys.partialTranscript)
+ return result
+ }
+}
+
+// MARK: - KeyboardViewModel
+
+@MainActor
+class KeyboardViewModel: ObservableObject {
+ @Published var state: KeyboardRecordingState = .idle
+ @Published var transcribedText: String = ""
+ @Published var partialText: String = ""
+ @Published var canUndo = false
+
+ private var lastInsertedText: String = ""
+ private let textDocumentProxy: UITextDocumentProxy
+ private weak var inputViewController: UIInputViewController?
+ private let sessionManager = FlowSessionManager.shared
+ private var statusMonitorTimer: Timer?
+ private var sessionMonitorTimer: Timer?
+ private var sessionTimeoutTask: DispatchWorkItem?
+
+ init(textDocumentProxy: UITextDocumentProxy, inputViewController: UIInputViewController) {
+ self.textDocumentProxy = textDocumentProxy
+ self.inputViewController = inputViewController
+ }
+
+ func refreshSessionState() {
+ stopStatusMonitoring()
+ if !sessionManager.isSessionActive {
+ if state != .idle && state != .needsSession { state = .idle }
+ } else {
+ let status = sessionManager.recordingStatus
+ if status == "idle" || status == "done" || status == "error" {
+ state = .idle
+ }
+ if status == "recording" || status == "processing" {
+ startStatusMonitoring()
+ }
+ }
+ }
+
+ func undoLastInsertion() {
+ guard canUndo, !lastInsertedText.isEmpty else { return }
+ for _ in 0.. String? {
+ let sel = NSSelectorFromString(key)
+ guard obj.responds(to: sel) else { return nil }
+ return obj.perform(sel)?.takeUnretainedValue() as? String
+ }
+
+ private func getHostAppBundleID() -> String? {
+ let keys = [
+ ["_", "host", "Bundle", "ID"].joined(),
+ ["_", "host", "Bundle", "Identifier"].joined(),
+ "hostBundleID",
+ "hostBundleIdentifier",
+ ]
+
+ // Responder chain
+ var responder: UIResponder? = inputViewController
+ while let r = responder {
+ let obj = r as NSObject
+ for key in keys {
+ if let bid = safeValue(forKey: key, on: obj),
+ !bid.isEmpty, !bid.contains("MiniFlow") {
+ return bid
+ }
+ }
+ responder = r.next
+ }
+
+ // Parent VC
+ if let parent = inputViewController?.parent as? NSObject {
+ for key in keys {
+ if let bid = safeValue(forKey: key, on: parent),
+ !bid.isEmpty, !bid.contains("MiniFlow") {
+ return bid
+ }
+ }
+ }
+
+ return nil
+ }
+
+ private func openURL(_ url: URL) {
+ guard let appClass = NSClassFromString("UIApplication") as? NSObject.Type else { return }
+ let sharedSel = NSSelectorFromString("sharedApplication")
+ guard appClass.responds(to: sharedSel),
+ let app = appClass.perform(sharedSel)?.takeUnretainedValue() else { return }
+
+ typealias OpenMethod = @convention(c) (AnyObject, Selector, URL, [UIApplication.OpenExternalURLOptionsKey: Any], ((Bool) -> Void)?) -> Void
+ let openSel = NSSelectorFromString("openURL:options:completionHandler:")
+ guard let method = class_getInstanceMethod(type(of: app), openSel) else { return }
+ let impl = method_getImplementation(method)
+ let open = unsafeBitCast(impl, to: OpenMethod.self)
+ open(app, openSel, url, [:], nil)
+ }
+
+ // MARK: - Monitoring
+
+ private func startSessionMonitoring() {
+ sessionMonitorTimer?.invalidate()
+ sessionTimeoutTask?.cancel()
+
+ sessionMonitorTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ if self.sessionManager.isSessionActive {
+ self.sessionMonitorTimer?.invalidate()
+ self.sessionMonitorTimer = nil
+ self.sessionTimeoutTask?.cancel()
+ self.state = .idle
+ }
+ }
+ }
+
+ let timeout = DispatchWorkItem { [weak self] in
+ Task { @MainActor [weak self] in
+ guard let self, self.state == .waitingForSession else { return }
+ self.sessionMonitorTimer?.invalidate()
+ self.sessionMonitorTimer = nil
+ self.state = .idle
+ }
+ }
+ sessionTimeoutTask = timeout
+ DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: timeout)
+ }
+
+ private func startStatusMonitoring() {
+ statusMonitorTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
+ Task { @MainActor [weak self] in self?.checkStatus() }
+ }
+ }
+
+ private func stopStatusMonitoring() {
+ statusMonitorTimer?.invalidate()
+ statusMonitorTimer = nil
+ }
+
+ private func checkStatus() {
+ if !sessionManager.isSessionActive {
+ stopStatusMonitoring()
+ state = .needsSession
+ openMainAppToStartFlow()
+ return
+ }
+
+ // Update partial transcript for live display
+ let partial = sessionManager.partialTranscript
+ if !partial.isEmpty { partialText = partial }
+
+ let status = sessionManager.recordingStatus
+
+ switch status {
+ case "recording":
+ if state != .recording { state = .recording }
+ case "processing":
+ if state != .processing { state = .processing }
+ case "done":
+ stopStatusMonitoring()
+ let result = sessionManager.consumeTranscriptionResult()
+ if !result.isEmpty {
+ let needsSpace: Bool
+ if let before = textDocumentProxy.documentContextBeforeInput, !before.isEmpty {
+ needsSpace = !before.last!.isWhitespace
+ } else {
+ needsSpace = false
+ }
+ let textToInsert = needsSpace ? " " + result : result
+ textDocumentProxy.insertText(textToInsert)
+ lastInsertedText = textToInsert
+ canUndo = true
+ partialText = ""
+
+ state = .success
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
+ self?.state = .idle
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 20) { [weak self] in
+ self?.canUndo = false
+ }
+ } else {
+ state = .idle
+ }
+ case "error":
+ stopStatusMonitoring()
+ let msg = sessionManager.errorMessage
+ state = .error(msg.isEmpty ? "Unknown error" : msg)
+ partialText = ""
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
+ self?.state = .idle
+ }
+ default:
+ break
+ }
+ }
+}
+
+// MARK: - State
+
+enum KeyboardRecordingState: Equatable {
+ case idle, needsSession, waitingForSession, recording, processing, success, error(String)
+}
diff --git a/MiniFlow_iOS/MiniFlowKeyboard/MiniFlowKeyboard.entitlements b/MiniFlow_iOS/MiniFlowKeyboard/MiniFlowKeyboard.entitlements
new file mode 100644
index 0000000..618363b
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlowKeyboard/MiniFlowKeyboard.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.smallestai.MiniFlow
+
+
+
diff --git a/MiniFlow_iOS/MiniFlowKeyboard/VoiceInputView.swift b/MiniFlow_iOS/MiniFlowKeyboard/VoiceInputView.swift
new file mode 100644
index 0000000..ca0693c
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlowKeyboard/VoiceInputView.swift
@@ -0,0 +1,354 @@
+import SwiftUI
+
+// MARK: - Theme
+
+private enum Theme {
+ private static let softSurface = UIColor { traits in
+ traits.userInterfaceStyle == .dark
+ ? UIColor(white: 0.22, alpha: 0.84)
+ : UIColor(white: 1.0, alpha: 0.88)
+ }
+
+ static let accent = Color(red: 0.18, green: 0.42, blue: 0.37) // MiniFlow teal (#2D6B5E)
+ static let recording = Color(red: 0.88, green: 0.22, blue: 0.28)
+ static let success = Color(red: 0.29, green: 0.71, blue: 0.45)
+ static let surface = Color(uiColor: softSurface)
+ static let surfaceBorder = Color(uiColor: .separator).opacity(0.16)
+ static let textPrimary = Color(uiColor: .label)
+ static let textSecondary = Color(uiColor: .secondaryLabel)
+}
+
+// MARK: - VoiceInputView
+
+struct VoiceInputView: View {
+ @ObservedObject var viewModel: KeyboardViewModel
+ let onSwitchKeyboard: () -> Void
+
+ var body: some View {
+ VStack(spacing: 10) {
+ headerRow
+ centerPanel
+ bottomControls
+ }
+ .padding(.horizontal, 12)
+ .padding(.top, 10)
+ .padding(.bottom, 10)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .background(Color.clear)
+ }
+
+ // MARK: - Header
+
+ private var headerRow: some View {
+ HStack(spacing: 8) {
+ Text("MiniFlow")
+ .font(.system(size: 13, weight: .bold))
+ .foregroundStyle(Theme.textPrimary)
+
+ Spacer(minLength: 0)
+
+ statusPill
+
+ Button(action: onSwitchKeyboard) {
+ Image(systemName: "globe")
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(Theme.textPrimary)
+ .frame(width: 34, height: 34)
+ .background(Circle().fill(Theme.surface))
+ .overlay(Circle().stroke(Theme.surfaceBorder, lineWidth: 1))
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var statusPill: some View {
+ switch viewModel.state {
+ case .recording:
+ HStack(spacing: 6) {
+ PulseDot(color: Theme.recording)
+ Text("Recording")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(Theme.textPrimary)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 7)
+ .background(Capsule().fill(Theme.recording.opacity(0.2)))
+ .overlay(Capsule().stroke(Theme.recording.opacity(0.45), lineWidth: 1))
+
+ case .processing:
+ HStack(spacing: 6) {
+ ProgressView().tint(Theme.textPrimary).scaleEffect(0.8)
+ Text("Processing")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(Theme.textPrimary)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 7)
+ .background(Capsule().fill(Theme.surface))
+ .overlay(Capsule().stroke(Theme.surfaceBorder, lineWidth: 1))
+
+ case .waitingForSession:
+ HStack(spacing: 6) {
+ ProgressView().tint(Theme.textPrimary).scaleEffect(0.8)
+ Text("Opening App")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(Theme.textPrimary)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 7)
+ .background(Capsule().fill(Theme.surface))
+ .overlay(Capsule().stroke(Theme.surfaceBorder, lineWidth: 1))
+
+ case .success:
+ HStack(spacing: 5) {
+ Image(systemName: "checkmark")
+ .font(.system(size: 11, weight: .bold))
+ Text("Inserted")
+ .font(.system(size: 12, weight: .semibold))
+ }
+ .foregroundStyle(Theme.textPrimary)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 7)
+ .background(Capsule().fill(Theme.success.opacity(0.26)))
+ .overlay(Capsule().stroke(Theme.success.opacity(0.45), lineWidth: 1))
+
+ case .error:
+ HStack(spacing: 5) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 11, weight: .semibold))
+ Text("Error")
+ .font(.system(size: 12, weight: .semibold))
+ }
+ .foregroundStyle(Theme.textPrimary)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 7)
+ .background(Capsule().fill(Theme.recording.opacity(0.2)))
+ .overlay(Capsule().stroke(Theme.recording.opacity(0.45), lineWidth: 1))
+
+ default:
+ EmptyView()
+ }
+ }
+
+ // MARK: - Center
+
+ private var centerPanel: some View {
+ VStack(spacing: 8) {
+ recordButton
+
+ switch viewModel.state {
+ case .recording:
+ if !viewModel.partialText.isEmpty {
+ ScrollViewReader { proxy in
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 0) {
+ Text(viewModel.partialText)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(Theme.textSecondary)
+ .fixedSize(horizontal: true, vertical: false)
+ Color.clear.frame(width: 1, height: 1).id("end")
+ }
+ }
+ .frame(height: 20)
+ .onChange(of: viewModel.partialText) {
+ proxy.scrollTo("end", anchor: .trailing)
+ }
+ .onAppear {
+ proxy.scrollTo("end", anchor: .trailing)
+ }
+ }
+ } else {
+ RecorderTicker(color: Theme.recording)
+ .frame(height: 20)
+ }
+ case .processing:
+ ProgressView().tint(Theme.textPrimary).scaleEffect(0.82)
+ .frame(height: 20)
+ case .waitingForSession:
+ Text("Launching MiniFlow")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(Theme.textSecondary)
+ .frame(height: 20)
+ case .error(let message):
+ Text(message)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(Theme.textSecondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .frame(height: 20)
+ default:
+ Color.clear.frame(height: 20)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.top, 2)
+ }
+
+ private var recordButton: some View {
+ Button(action: viewModel.toggleRecording) {
+ ZStack {
+ Circle()
+ .fill(buttonFill)
+ .frame(width: 84, height: 84)
+ .overlay(Circle().stroke(buttonStroke, lineWidth: 1.5))
+ .shadow(color: .black.opacity(0.25), radius: 7, y: 3)
+
+ buttonIcon.foregroundStyle(buttonIconColor)
+ }
+ }
+ .buttonStyle(.plain)
+ .disabled(viewModel.state == .processing || viewModel.state == .waitingForSession)
+ .opacity(viewModel.state == .processing || viewModel.state == .waitingForSession ? 0.7 : 1)
+ }
+
+ private var buttonFill: Color {
+ switch viewModel.state {
+ case .recording: return Theme.recording
+ case .processing: return Theme.surface
+ case .success: return Theme.success.opacity(0.9)
+ default: return Theme.accent
+ }
+ }
+
+ private var buttonStroke: Color {
+ switch viewModel.state {
+ case .recording: return Theme.recording.opacity(0.95)
+ case .processing: return Theme.surfaceBorder
+ case .success: return Theme.success.opacity(0.95)
+ default: return Theme.accent.opacity(0.95)
+ }
+ }
+
+ private var buttonIconColor: Color {
+ viewModel.state == .processing ? Theme.textSecondary : .white
+ }
+
+ @ViewBuilder
+ private var buttonIcon: some View {
+ switch viewModel.state {
+ case .processing:
+ ProgressView().tint(Theme.textPrimary).scaleEffect(1.05)
+ case .recording:
+ RoundedRectangle(cornerRadius: 4).frame(width: 22, height: 22)
+ case .success:
+ Image(systemName: "checkmark").font(.system(size: 26, weight: .bold))
+ default:
+ Image(systemName: "mic.fill").font(.system(size: 30, weight: .semibold))
+ }
+ }
+
+ // MARK: - Bottom Controls
+
+ private var bottomControls: some View {
+ HStack(spacing: 8) {
+ if viewModel.state == .recording || viewModel.state == .processing {
+ ControlButton(icon: "xmark", tint: Theme.recording, action: viewModel.cancelRecording)
+ } else {
+ ControlButton(icon: "trash", tint: Theme.textPrimary, action: viewModel.clearAll)
+ }
+
+ ControlButton(
+ icon: "arrow.uturn.backward",
+ tint: Theme.textPrimary,
+ isDisabled: !viewModel.canUndo,
+ action: viewModel.undoLastInsertion
+ )
+
+ ControlButton(icon: "delete.left", tint: Theme.textPrimary, action: viewModel.deleteBackward)
+
+ ControlButton(icon: "return", tint: Theme.textPrimary, action: viewModel.insertReturn)
+ }
+ }
+}
+
+// MARK: - Reusable Components
+
+private struct ControlButton: View {
+ let icon: String
+ let tint: Color
+ var isDisabled: Bool = false
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Image(systemName: icon)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(tint)
+ .frame(maxWidth: .infinity)
+ .frame(height: 36)
+ .background(RoundedRectangle(cornerRadius: 10).fill(Theme.surface))
+ .overlay(RoundedRectangle(cornerRadius: 10).stroke(Theme.surfaceBorder, lineWidth: 1))
+ }
+ .buttonStyle(.plain)
+ .disabled(isDisabled)
+ .opacity(isDisabled ? 0.4 : 1)
+ }
+}
+
+private struct PulseDot: View {
+ let color: Color
+ @State private var isOn = false
+
+ var body: some View {
+ Circle()
+ .fill(color)
+ .frame(width: 8, height: 8)
+ .scaleEffect(isOn ? 1.1 : 0.85)
+ .opacity(isOn ? 1 : 0.7)
+ .onAppear {
+ withAnimation(.easeInOut(duration: 0.75).repeatForever(autoreverses: true)) {
+ isOn = true
+ }
+ }
+ }
+}
+
+private struct RecorderTicker: View {
+ let color: Color
+
+ var body: some View {
+ TimelineView(.animation(minimumInterval: 0.15, paused: false)) { timeline in
+ let t = timeline.date.timeIntervalSinceReferenceDate
+ HStack(alignment: .center, spacing: 4) {
+ ForEach(0..<6, id: \.self) { i in
+ let wave = abs(sin(t * 3.1 + Double(i) * 0.55))
+ let height = 6 + (wave * 12)
+ RoundedRectangle(cornerRadius: 2)
+ .fill(color)
+ .frame(width: 4, height: height)
+ }
+ }
+ .frame(height: 20)
+ }
+ }
+}
+
+struct RepeatableButton: View {
+ let action: () -> Void
+ let label: () -> Label
+
+ @State private var timer: Timer?
+
+ init(action: @escaping () -> Void, @ViewBuilder label: @escaping () -> Label) {
+ self.action = action
+ self.label = label
+ }
+
+ var body: some View {
+ label()
+ .frame(maxWidth: .infinity)
+ .gesture(
+ DragGesture(minimumDistance: 0)
+ .onChanged { _ in
+ if timer == nil {
+ action()
+ timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in action() }
+ }
+ }
+ .onEnded { _ in
+ timer?.invalidate()
+ timer = nil
+ }
+ )
+ }
+}
diff --git a/MiniFlow_iOS/MiniFlowKeyboardInfo.plist b/MiniFlow_iOS/MiniFlowKeyboardInfo.plist
new file mode 100644
index 0000000..97f56f8
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlowKeyboardInfo.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.keyboard-service
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).KeyboardViewController
+ NSExtensionAttributes
+
+ IsASCIICapable
+
+ PrefersRightToLeft
+
+ PrimaryLanguage
+ en-US
+ RequestsOpenAccess
+
+
+
+
+
diff --git a/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/project.pbxproj b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..8c45f9b
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/project.pbxproj
@@ -0,0 +1,739 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 7F486E072F79FB9E00C3555D /* MiniFlowKeyboard.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7F486E002F79FB9E00C3555D /* MiniFlowKeyboard.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 7F486DCA2F79F7D500C3555D /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 7F486DB42F79F7D400C3555D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 7F486DBB2F79F7D400C3555D;
+ remoteInfo = MiniFlow_iOS;
+ };
+ 7F486DD42F79F7D500C3555D /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 7F486DB42F79F7D400C3555D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 7F486DBB2F79F7D400C3555D;
+ remoteInfo = MiniFlow_iOS;
+ };
+ 7F486E052F79FB9E00C3555D /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 7F486DB42F79F7D400C3555D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 7F486DFF2F79FB9E00C3555D;
+ remoteInfo = MiniFlowKeyboard;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 7F486E082F79FB9E00C3555D /* Embed Foundation Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 7F486E072F79FB9E00C3555D /* MiniFlowKeyboard.appex in Embed Foundation Extensions */,
+ );
+ name = "Embed Foundation Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 7F486DBC2F79F7D400C3555D /* MiniFlow_iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MiniFlow_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 7F486DC92F79F7D500C3555D /* MiniFlow_iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MiniFlow_iOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 7F486DD32F79F7D500C3555D /* MiniFlow_iOSUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MiniFlow_iOSUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 7F486E002F79FB9E00C3555D /* MiniFlowKeyboard.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MiniFlowKeyboard.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 7F486DBE2F79F7D400C3555D /* MiniFlow_iOS */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = MiniFlow_iOS;
+ sourceTree = "";
+ };
+ 7F486DCC2F79F7D500C3555D /* MiniFlow_iOSTests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = MiniFlow_iOSTests;
+ sourceTree = "";
+ };
+ 7F486DD62F79F7D500C3555D /* MiniFlow_iOSUITests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = MiniFlow_iOSUITests;
+ sourceTree = "";
+ };
+ 7F486E012F79FB9E00C3555D /* MiniFlowKeyboard */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = MiniFlowKeyboard;
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 7F486DB92F79F7D400C3555D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 7F486DC62F79F7D500C3555D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 7F486DD02F79F7D500C3555D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 7F486DFD2F79FB9E00C3555D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 7F486DB32F79F7D400C3555D = {
+ isa = PBXGroup;
+ children = (
+ 7F486DBE2F79F7D400C3555D /* MiniFlow_iOS */,
+ 7F486DCC2F79F7D500C3555D /* MiniFlow_iOSTests */,
+ 7F486DD62F79F7D500C3555D /* MiniFlow_iOSUITests */,
+ 7F486E012F79FB9E00C3555D /* MiniFlowKeyboard */,
+ 7F486DBD2F79F7D400C3555D /* Products */,
+ );
+ sourceTree = "";
+ };
+ 7F486DBD2F79F7D400C3555D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 7F486DBC2F79F7D400C3555D /* MiniFlow_iOS.app */,
+ 7F486DC92F79F7D500C3555D /* MiniFlow_iOSTests.xctest */,
+ 7F486DD32F79F7D500C3555D /* MiniFlow_iOSUITests.xctest */,
+ 7F486E002F79FB9E00C3555D /* MiniFlowKeyboard.appex */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 7F486DBB2F79F7D400C3555D /* MiniFlow_iOS */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 7F486DDD2F79F7D500C3555D /* Build configuration list for PBXNativeTarget "MiniFlow_iOS" */;
+ buildPhases = (
+ 7F486DB82F79F7D400C3555D /* Sources */,
+ 7F486DB92F79F7D400C3555D /* Frameworks */,
+ 7F486DBA2F79F7D400C3555D /* Resources */,
+ 7F486E082F79FB9E00C3555D /* Embed Foundation Extensions */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 7F486E062F79FB9E00C3555D /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 7F486DBE2F79F7D400C3555D /* MiniFlow_iOS */,
+ );
+ name = MiniFlow_iOS;
+ packageProductDependencies = (
+ );
+ productName = MiniFlow_iOS;
+ productReference = 7F486DBC2F79F7D400C3555D /* MiniFlow_iOS.app */;
+ productType = "com.apple.product-type.application";
+ };
+ 7F486DC82F79F7D500C3555D /* MiniFlow_iOSTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 7F486DE02F79F7D500C3555D /* Build configuration list for PBXNativeTarget "MiniFlow_iOSTests" */;
+ buildPhases = (
+ 7F486DC52F79F7D500C3555D /* Sources */,
+ 7F486DC62F79F7D500C3555D /* Frameworks */,
+ 7F486DC72F79F7D500C3555D /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 7F486DCB2F79F7D500C3555D /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 7F486DCC2F79F7D500C3555D /* MiniFlow_iOSTests */,
+ );
+ name = MiniFlow_iOSTests;
+ packageProductDependencies = (
+ );
+ productName = MiniFlow_iOSTests;
+ productReference = 7F486DC92F79F7D500C3555D /* MiniFlow_iOSTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 7F486DD22F79F7D500C3555D /* MiniFlow_iOSUITests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 7F486DE32F79F7D500C3555D /* Build configuration list for PBXNativeTarget "MiniFlow_iOSUITests" */;
+ buildPhases = (
+ 7F486DCF2F79F7D500C3555D /* Sources */,
+ 7F486DD02F79F7D500C3555D /* Frameworks */,
+ 7F486DD12F79F7D500C3555D /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 7F486DD52F79F7D500C3555D /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 7F486DD62F79F7D500C3555D /* MiniFlow_iOSUITests */,
+ );
+ name = MiniFlow_iOSUITests;
+ packageProductDependencies = (
+ );
+ productName = MiniFlow_iOSUITests;
+ productReference = 7F486DD32F79F7D500C3555D /* MiniFlow_iOSUITests.xctest */;
+ productType = "com.apple.product-type.bundle.ui-testing";
+ };
+ 7F486DFF2F79FB9E00C3555D /* MiniFlowKeyboard */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 7F486E0C2F79FB9E00C3555D /* Build configuration list for PBXNativeTarget "MiniFlowKeyboard" */;
+ buildPhases = (
+ 7F486DFC2F79FB9E00C3555D /* Sources */,
+ 7F486DFD2F79FB9E00C3555D /* Frameworks */,
+ 7F486DFE2F79FB9E00C3555D /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 7F486E012F79FB9E00C3555D /* MiniFlowKeyboard */,
+ );
+ name = MiniFlowKeyboard;
+ packageProductDependencies = (
+ );
+ productName = MiniFlowKeyboard;
+ productReference = 7F486E002F79FB9E00C3555D /* MiniFlowKeyboard.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 7F486DB42F79F7D400C3555D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2600;
+ LastUpgradeCheck = 2600;
+ TargetAttributes = {
+ 7F486DBB2F79F7D400C3555D = {
+ CreatedOnToolsVersion = 26.0.1;
+ };
+ 7F486DC82F79F7D500C3555D = {
+ CreatedOnToolsVersion = 26.0.1;
+ TestTargetID = 7F486DBB2F79F7D400C3555D;
+ };
+ 7F486DD22F79F7D500C3555D = {
+ CreatedOnToolsVersion = 26.0.1;
+ TestTargetID = 7F486DBB2F79F7D400C3555D;
+ };
+ 7F486DFF2F79FB9E00C3555D = {
+ CreatedOnToolsVersion = 26.0.1;
+ };
+ };
+ };
+ buildConfigurationList = 7F486DB72F79F7D400C3555D /* Build configuration list for PBXProject "MiniFlow_iOS" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 7F486DB32F79F7D400C3555D;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 7F486DBD2F79F7D400C3555D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 7F486DBB2F79F7D400C3555D /* MiniFlow_iOS */,
+ 7F486DC82F79F7D500C3555D /* MiniFlow_iOSTests */,
+ 7F486DD22F79F7D500C3555D /* MiniFlow_iOSUITests */,
+ 7F486DFF2F79FB9E00C3555D /* MiniFlowKeyboard */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 7F486DBA2F79F7D400C3555D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 7F486DC72F79F7D500C3555D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 7F486DD12F79F7D500C3555D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 7F486DFE2F79FB9E00C3555D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 7F486DB82F79F7D400C3555D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 7F486DC52F79F7D500C3555D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 7F486DCF2F79F7D500C3555D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 7F486DFC2F79FB9E00C3555D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 7F486DCB2F79F7D500C3555D /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 7F486DBB2F79F7D400C3555D /* MiniFlow_iOS */;
+ targetProxy = 7F486DCA2F79F7D500C3555D /* PBXContainerItemProxy */;
+ };
+ 7F486DD52F79F7D500C3555D /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 7F486DBB2F79F7D400C3555D /* MiniFlow_iOS */;
+ targetProxy = 7F486DD42F79F7D500C3555D /* PBXContainerItemProxy */;
+ };
+ 7F486E062F79FB9E00C3555D /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 7F486DFF2F79FB9E00C3555D /* MiniFlowKeyboard */;
+ targetProxy = 7F486E052F79FB9E00C3555D /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+ 7F486DDB2F79F7D500C3555D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 7F486DDC2F79F7D500C3555D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 7F486DDE2F79F7D500C3555D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = MiniFlow_iOS/MiniFlow_iOS.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = UW4BNRV4Y4;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "MiniFlow-iOS-Info.plist";
+ INFOPLIST_KEY_LSApplicationCategoryType = "";
+ INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone access is required essentially. ";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOS";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 7F486DDF2F79F7D500C3555D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = MiniFlow_iOS/MiniFlow_iOS.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = UW4BNRV4Y4;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "MiniFlow-iOS-Info.plist";
+ INFOPLIST_KEY_LSApplicationCategoryType = "";
+ INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone access is required essentially. ";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOS";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ 7F486DE12F79F7D500C3555D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOSTests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MiniFlow_iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MiniFlow_iOS";
+ };
+ name = Debug;
+ };
+ 7F486DE22F79F7D500C3555D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOSTests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MiniFlow_iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MiniFlow_iOS";
+ };
+ name = Release;
+ };
+ 7F486DE42F79F7D500C3555D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOSUITests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = MiniFlow_iOS;
+ };
+ name = Debug;
+ };
+ 7F486DE52F79F7D500C3555D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOSUITests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = MiniFlow_iOS;
+ };
+ name = Release;
+ };
+ 7F486E092F79FB9E00C3555D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = MiniFlowKeyboard/MiniFlowKeyboard.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = UW4BNRV4Y4;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = MiniFlowKeyboardInfo.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = MiniFlowKeyboard;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOS.MiniFlowKeyboard";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 7F486E0A2F79FB9E00C3555D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = MiniFlowKeyboard/MiniFlowKeyboard.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = UW4BNRV4Y4;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = MiniFlowKeyboardInfo.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = MiniFlowKeyboard;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.smallestai.MiniFlow-iOS.MiniFlowKeyboard";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 7F486DB72F79F7D400C3555D /* Build configuration list for PBXProject "MiniFlow_iOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7F486DDB2F79F7D500C3555D /* Debug */,
+ 7F486DDC2F79F7D500C3555D /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 7F486DDD2F79F7D500C3555D /* Build configuration list for PBXNativeTarget "MiniFlow_iOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7F486DDE2F79F7D500C3555D /* Debug */,
+ 7F486DDF2F79F7D500C3555D /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 7F486DE02F79F7D500C3555D /* Build configuration list for PBXNativeTarget "MiniFlow_iOSTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7F486DE12F79F7D500C3555D /* Debug */,
+ 7F486DE22F79F7D500C3555D /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 7F486DE32F79F7D500C3555D /* Build configuration list for PBXNativeTarget "MiniFlow_iOSUITests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7F486DE42F79F7D500C3555D /* Debug */,
+ 7F486DE52F79F7D500C3555D /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 7F486E0C2F79FB9E00C3555D /* Build configuration list for PBXNativeTarget "MiniFlowKeyboard" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7F486E092F79FB9E00C3555D /* Debug */,
+ 7F486E0A2F79FB9E00C3555D /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 7F486DB42F79F7D400C3555D /* Project object */;
+}
diff --git a/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/xcshareddata/xcschemes/MiniFlowKeyboard.xcscheme b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/xcshareddata/xcschemes/MiniFlowKeyboard.xcscheme
new file mode 100644
index 0000000..5fbac96
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/xcshareddata/xcschemes/MiniFlowKeyboard.xcscheme
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/xcshareddata/xcschemes/MiniFlow_iOS.xcscheme b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/xcshareddata/xcschemes/MiniFlow_iOS.xcscheme
new file mode 100644
index 0000000..b55b71a
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS.xcodeproj/xcshareddata/xcschemes/MiniFlow_iOS.xcscheme
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/AccentColor.colorset/Contents.json b/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..2305880
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,35 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/Contents.json b/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/MiniFlow_iOS/MiniFlow_iOS/ContentView.swift b/MiniFlow_iOS/MiniFlow_iOS/ContentView.swift
new file mode 100644
index 0000000..a7539fa
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS/ContentView.swift
@@ -0,0 +1,192 @@
+import SwiftUI
+import AVFoundation
+
+struct ContentView: View {
+ @EnvironmentObject var flowRecorder: FlowBackgroundRecorder
+
+ @State private var apiKey = ""
+ @State private var keySaved = false
+ @State private var micGranted = false
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 24) {
+
+ // Session status card
+ sessionCard
+
+ // API Key
+ apiKeySection
+
+ // Keyboard setup instructions
+ keyboardInstructions
+
+ // Mic permission
+ micPermissionSection
+ }
+ .padding(20)
+ }
+ .navigationTitle("MiniFlow")
+ .background(Color(UIColor.systemGroupedBackground))
+ }
+ .task {
+ apiKey = KeychainHelper.smallestAPIKey ?? ""
+ micGranted = AVAudioApplication.shared.recordPermission == .granted
+ }
+ }
+
+ // MARK: - Session Card
+
+ private var sessionCard: some View {
+ VStack(spacing: 16) {
+ Image(systemName: flowRecorder.isSessionActive ? "waveform.circle.fill" : "waveform.circle")
+ .font(.system(size: 48))
+ .foregroundStyle(flowRecorder.isSessionActive ? .green : .secondary)
+
+ Text(flowRecorder.isSessionActive ? "Session Active" : "Session Inactive")
+ .font(.headline)
+
+ if flowRecorder.isRecording {
+ Text("Recording...")
+ .font(.subheadline)
+ .foregroundStyle(.red)
+ }
+
+ Button {
+ if flowRecorder.isSessionActive {
+ flowRecorder.endFlowSession()
+ } else {
+ flowRecorder.startFlowSession()
+ }
+ } label: {
+ Text(flowRecorder.isSessionActive ? "End Session" : "Start Session")
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(.white)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(flowRecorder.isSessionActive ? Color.red : Color(hex: "2D6B5E"))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ }
+ .padding(20)
+ .background(Color(UIColor.secondarySystemGroupedBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .shadow(color: .black.opacity(0.05), radius: 4, y: 2)
+ }
+
+ // MARK: - API Key
+
+ private var apiKeySection: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Smallest AI API Key")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ HStack(spacing: 10) {
+ SecureField("sk_...", text: $apiKey)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 14))
+
+ Button {
+ KeychainHelper.smallestAPIKey = apiKey
+ keySaved = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) { keySaved = false }
+ } label: {
+ Text(keySaved ? "Saved" : "Save")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .background(apiKey.isEmpty ? Color.gray : Color(hex: "2D6B5E"))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+ .disabled(apiKey.isEmpty)
+ }
+
+ Text("Get your key from app.smallest.ai")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ }
+ .padding(16)
+ .background(Color(UIColor.secondarySystemGroupedBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+
+ // MARK: - Keyboard Instructions
+
+ private var keyboardInstructions: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Label("Enable Keyboard", systemImage: "keyboard")
+ .font(.system(size: 15, weight: .semibold))
+
+ VStack(alignment: .leading, spacing: 8) {
+ instructionRow(number: "1", text: "Settings → General → Keyboard → Keyboards")
+ instructionRow(number: "2", text: "Add New Keyboard → MiniFlow")
+ instructionRow(number: "3", text: "Tap MiniFlow → Enable \"Allow Full Access\"")
+ instructionRow(number: "4", text: "Switch to MiniFlow keyboard in any app")
+ }
+ }
+ .padding(16)
+ .background(Color(UIColor.secondarySystemGroupedBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+
+ private func instructionRow(number: String, text: String) -> some View {
+ HStack(alignment: .top, spacing: 10) {
+ Text(number)
+ .font(.system(size: 12, weight: .bold))
+ .foregroundStyle(.white)
+ .frame(width: 22, height: 22)
+ .background(Color(hex: "2D6B5E"))
+ .clipShape(Circle())
+ Text(text)
+ .font(.system(size: 13))
+ .foregroundStyle(.primary)
+ }
+ }
+
+ // MARK: - Mic Permission
+
+ private var micPermissionSection: some View {
+ HStack {
+ Image(systemName: micGranted ? "checkmark.circle.fill" : "mic.slash")
+ .foregroundStyle(micGranted ? .green : .orange)
+ Text(micGranted ? "Microphone access granted" : "Microphone access needed")
+ .font(.system(size: 13))
+ Spacer()
+ if !micGranted {
+ Button("Grant") {
+ Task {
+ micGranted = await AVAudioApplication.requestRecordPermission()
+ }
+ }
+ .font(.system(size: 13, weight: .medium))
+ }
+ }
+ .padding(16)
+ .background(Color(UIColor.secondarySystemGroupedBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+}
+
+// MARK: - Color+Hex
+
+extension Color {
+ init(hex: String) {
+ let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
+ var int: UInt64 = 0
+ Scanner(string: hex).scanHexInt64(&int)
+ let r, g, b: Double
+ switch hex.count {
+ case 6:
+ r = Double((int >> 16) & 0xFF) / 255
+ g = Double((int >> 8) & 0xFF) / 255
+ b = Double(int & 0xFF) / 255
+ default:
+ r = 0; g = 0; b = 0
+ }
+ self.init(red: r, green: g, blue: b)
+ }
+}
diff --git a/MiniFlow_iOS/MiniFlow_iOS/MiniFlow_iOS.entitlements b/MiniFlow_iOS/MiniFlow_iOS/MiniFlow_iOS.entitlements
new file mode 100644
index 0000000..618363b
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS/MiniFlow_iOS.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.smallestai.MiniFlow
+
+
+
diff --git a/MiniFlow_iOS/MiniFlow_iOS/MiniFlow_iOSApp.swift b/MiniFlow_iOS/MiniFlow_iOS/MiniFlow_iOSApp.swift
new file mode 100644
index 0000000..c8b67fa
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS/MiniFlow_iOSApp.swift
@@ -0,0 +1,153 @@
+import SwiftUI
+import UIKit
+
+// MARK: - Global State
+
+enum AppState {
+ static var sourceAppBundleID: String?
+ static var pendingReturnToApp = false
+}
+
+// MARK: - Scene Delegate (URL scheme handling)
+
+class MiniFlowSceneDelegate: NSObject, UIWindowSceneDelegate {
+
+ func scene(_ scene: UIScene, openURLContexts URLContexts: Set) {
+ guard let context = URLContexts.first else { return }
+ if let sourceApp = context.options.sourceApplication {
+ AppState.sourceAppBundleID = sourceApp
+ } else {
+ AppState.sourceAppBundleID = Self.getPreviousAppBundleID()
+ }
+ handleURL(context.url)
+ }
+
+ func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
+ if let context = connectionOptions.urlContexts.first {
+ if let sourceApp = context.options.sourceApplication {
+ AppState.sourceAppBundleID = sourceApp
+ } else {
+ AppState.sourceAppBundleID = Self.getPreviousAppBundleID()
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
+ self?.handleURL(context.url)
+ }
+ }
+ }
+
+ func sceneWillEnterForeground(_ scene: UIScene) {
+ if AppState.pendingReturnToApp {
+ AppState.pendingReturnToApp = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ Self.returnToSourceApp()
+ }
+ }
+ }
+
+ // MARK: - URL Handling
+
+ private func handleURL(_ url: URL) {
+ guard url.scheme == "miniflow" else { return }
+
+ if url.host == "startflow" {
+ FlowBackgroundRecorder.shared.startFlowSession()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ Self.returnToSourceApp()
+ }
+ } else if url.host == "stopflow" {
+ FlowBackgroundRecorder.shared.endFlowSession()
+ }
+ }
+
+ // MARK: - Source App Detection (private APIs, obfuscated)
+
+ static func getPreviousAppBundleID() -> String? {
+ // Priority 1: Check UserDefaults (keyboard saved the host bundle ID)
+ if let defaults = UserDefaults(suiteName: "group.com.smallestai.MiniFlow") {
+ defaults.synchronize()
+ if let bundleID = defaults.string(forKey: "flow_return_app_bundle_id"),
+ !bundleID.isEmpty,
+ !bundleID.contains("MiniFlow") {
+ return bundleID
+ }
+ }
+
+ // Priority 2: FBSSystemService
+ let fbsClassName = ["FBS", "System", "Service"].joined()
+ if let fbsClass = NSClassFromString(fbsClassName),
+ let service = (fbsClass as AnyObject).perform(NSSelectorFromString("sharedService"))?.takeUnretainedValue() {
+ for sel in ["previousApplication", "topApplication"] {
+ let selector = NSSelectorFromString(sel)
+ if (service as AnyObject).responds(to: selector),
+ let result = (service as AnyObject).perform(selector)?.takeUnretainedValue(),
+ let bundleID = result as? String,
+ !bundleID.contains("MiniFlow") {
+ return bundleID
+ }
+ }
+ }
+
+ return nil
+ }
+
+ static func returnToSourceApp() {
+ if let bundleID = AppState.sourceAppBundleID, !bundleID.isEmpty,
+ !bundleID.contains("MiniFlow") {
+ AppState.sourceAppBundleID = nil
+ openAppWithBundleID(bundleID)
+ return
+ }
+
+ if let defaults = UserDefaults(suiteName: "group.com.smallestai.MiniFlow") {
+ defaults.synchronize()
+ if let bundleID = defaults.string(forKey: "flow_return_app_bundle_id"),
+ !bundleID.isEmpty,
+ !bundleID.contains("MiniFlow") {
+ defaults.removeObject(forKey: "flow_return_app_bundle_id")
+ defaults.synchronize()
+ openAppWithBundleID(bundleID)
+ return
+ }
+ }
+ }
+
+ static func openAppWithBundleID(_ bundleID: String) {
+ let className = ["LS", "Application", "Workspace"].joined()
+ let methodName = ["open", "Application", "With", "BundleID:"].joined()
+
+ guard let workspaceClass = NSClassFromString(className),
+ workspaceClass.responds(to: NSSelectorFromString("defaultWorkspace")),
+ let workspace = (workspaceClass as AnyObject).perform(NSSelectorFromString("defaultWorkspace"))?.takeUnretainedValue()
+ else { return }
+
+ let openSelector = NSSelectorFromString(methodName)
+ if (workspace as AnyObject).responds(to: openSelector) {
+ _ = (workspace as AnyObject).perform(openSelector, with: bundleID)
+ }
+ }
+}
+
+// MARK: - App Delegate
+
+class MiniFlowAppDelegate: NSObject, UIApplicationDelegate {
+ func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
+ let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
+ config.delegateClass = MiniFlowSceneDelegate.self
+ return config
+ }
+}
+
+// MARK: - App Entry Point
+
+@main
+struct MiniFlow_iOSApp: App {
+ @UIApplicationDelegateAdaptor(MiniFlowAppDelegate.self) var appDelegate
+ @StateObject private var flowRecorder = FlowBackgroundRecorder.shared
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .environmentObject(flowRecorder)
+ }
+ }
+}
diff --git a/MiniFlow_iOS/MiniFlow_iOS/Services/FlowBackgroundRecorder.swift b/MiniFlow_iOS/MiniFlow_iOS/Services/FlowBackgroundRecorder.swift
new file mode 100644
index 0000000..7d8b38f
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS/Services/FlowBackgroundRecorder.swift
@@ -0,0 +1,292 @@
+import Foundation
+@preconcurrency import AVFoundation
+import UIKit
+import Combine
+
+/// Background audio recorder that streams PCM chunks to Smallest AI via WebSocket.
+@MainActor
+class FlowBackgroundRecorder: ObservableObject {
+ static let shared = FlowBackgroundRecorder()
+
+ @Published var isSessionActive = false
+ @Published var isRecording = false
+
+ private var audioEngine: AVAudioEngine?
+ private var inputNode: AVAudioInputNode?
+
+ private let targetFormat = AVAudioFormat(
+ commonFormat: .pcmFormatInt16,
+ sampleRate: 16_000,
+ channels: 1,
+ interleaved: true
+ )!
+
+ private var sttClient: SmallestAIClient?
+ private var commandMonitorTimer: DispatchSourceTimer?
+ private var autoStopTimer: DispatchSourceTimer?
+ private let sessionManager = FlowSessionManager.shared
+
+ private static let maxRecordingSeconds: TimeInterval = 60
+
+ private init() {
+ NotificationCenter.default.addObserver(
+ forName: AVAudioSession.interruptionNotification,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor [weak self] in
+ guard let self, self.isSessionActive else { return }
+ try? self.audioEngine?.start()
+ }
+ }
+ }
+
+ // MARK: - Session Lifecycle
+
+ func startFlowSession() {
+ guard !isSessionActive else { return }
+ print("[MiniFlow] Starting Flow Session...")
+
+ let session = AVAudioSession.sharedInstance()
+ do {
+ try session.setCategory(
+ .playAndRecord,
+ mode: .default,
+ options: [.defaultToSpeaker, .mixWithOthers, .allowBluetoothHFP]
+ )
+ try session.setActive(true)
+ } catch {
+ print("[MiniFlow] Failed to configure audio session: \(error)")
+ return
+ }
+
+ guard setupAudioEngine() else {
+ print("[MiniFlow] Failed to setup audio engine")
+ return
+ }
+
+ sessionManager.startSession()
+ isSessionActive = true
+ startCommandMonitoring()
+ print("[MiniFlow] Flow session started")
+ }
+
+ func endFlowSession() {
+ print("[MiniFlow] Ending Flow Session...")
+ stopCommandMonitoring()
+ stopAudioEngine()
+
+ let session = AVAudioSession.sharedInstance()
+ try? session.setActive(false, options: .notifyOthersOnDeactivation)
+
+ sessionManager.endSession()
+ isSessionActive = false
+ isRecording = false
+ print("[MiniFlow] Flow session ended")
+ }
+
+ // MARK: - Audio Engine
+
+ private func setupAudioEngine() -> Bool {
+ let engine = AVAudioEngine()
+ let input = engine.inputNode
+ let nativeFormat = input.outputFormat(forBus: 0)
+
+ print("[MiniFlow] Native audio: \(nativeFormat.sampleRate)Hz, \(nativeFormat.channelCount)ch")
+
+ let bufferSize = AVAudioFrameCount(nativeFormat.sampleRate * 0.1)
+
+ input.installTap(onBus: 0, bufferSize: bufferSize, format: nativeFormat) { [weak self] buffer, _ in
+ let pcm = self?.convertToPCM16(buffer: buffer, from: nativeFormat)
+ Task { @MainActor [weak self] in
+ guard let self, self.isRecording, let pcm else { return }
+ self.sendChunk(pcm)
+ }
+ }
+
+ do {
+ try engine.start()
+ audioEngine = engine
+ inputNode = input
+ print("[MiniFlow] Audio engine started")
+ return true
+ } catch {
+ print("[MiniFlow] Failed to start audio engine: \(error)")
+ return false
+ }
+ }
+
+ private func stopAudioEngine() {
+ inputNode?.removeTap(onBus: 0)
+ audioEngine?.stop()
+ audioEngine = nil
+ inputNode = nil
+ }
+
+ // MARK: - PCM Conversion
+
+ private nonisolated func convertToPCM16(buffer: AVAudioPCMBuffer, from srcFormat: AVAudioFormat) -> Data? {
+ guard let converter = AVAudioConverter(from: srcFormat, to: targetFormat) else { return nil }
+ let ratio = targetFormat.sampleRate / srcFormat.sampleRate
+ let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
+ guard capacity > 0,
+ let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity)
+ else { return nil }
+
+ var error: NSError?
+ var consumed = false
+ converter.convert(to: outBuffer, error: &error) { _, outStatus in
+ if consumed {
+ outStatus.pointee = .noDataNow
+ return nil
+ }
+ outStatus.pointee = .haveData
+ consumed = true
+ return buffer
+ }
+
+ guard error == nil, let channelData = outBuffer.int16ChannelData else { return nil }
+ let byteCount = Int(outBuffer.frameLength) * MemoryLayout.size
+ return Data(bytes: channelData[0], count: byteCount)
+ }
+
+ // MARK: - Streaming STT
+
+ private func sendChunk(_ pcm: Data) {
+ guard let client = sttClient else { return }
+ Task { await client.sendChunk(pcm) }
+ }
+
+ // MARK: - Command Monitoring
+
+ private func startCommandMonitoring() {
+ let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .userInteractive))
+ timer.schedule(deadline: .now(), repeating: .milliseconds(100))
+ timer.setEventHandler { [weak self] in
+ Task { @MainActor [weak self] in self?.checkForCommands() }
+ }
+ timer.resume()
+ commandMonitorTimer = timer
+ }
+
+ private func stopCommandMonitoring() {
+ commandMonitorTimer?.cancel()
+ commandMonitorTimer = nil
+ }
+
+ private func checkForCommands() {
+ let command = sessionManager.recordingCommand
+ switch command {
+ case .start:
+ if !isRecording {
+ sessionManager.clearCommand()
+ startRecording()
+ }
+ case .stop:
+ if isRecording {
+ sessionManager.clearCommand()
+ stopRecording()
+ }
+ case .cancel:
+ sessionManager.clearCommand()
+ cancelRecording()
+ case .none:
+ break
+ }
+ }
+
+ // MARK: - Recording Control
+
+ private func startRecording() {
+ guard audioEngine?.isRunning == true else {
+ sessionManager.recordingStatus = .error
+ sessionManager.errorMessage = "Session expired. Please restart."
+ sessionManager.endSession()
+ isSessionActive = false
+ return
+ }
+
+ guard let apiKey = KeychainHelper.smallestAPIKey, !apiKey.isEmpty else {
+ sessionManager.recordingStatus = .error
+ sessionManager.errorMessage = "API key not set. Open MiniFlow to add your key."
+ return
+ }
+
+ let client = SmallestAIClient { [weak self] partial in
+ Task { @MainActor [weak self] in
+ self?.sessionManager.partialTranscript = partial
+ }
+ }
+ sttClient = client
+
+ Task {
+ do {
+ try await client.startSession(apiKey: apiKey)
+ isRecording = true
+ sessionManager.recordingStatus = .recording
+ sessionManager.partialTranscript = ""
+ print("[MiniFlow] Recording started, streaming to Smallest AI")
+ } catch {
+ sessionManager.recordingStatus = .error
+ sessionManager.errorMessage = "Failed to connect: \(error.localizedDescription)"
+ sttClient = nil
+ }
+ }
+
+ let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
+ timer.schedule(deadline: .now() + Self.maxRecordingSeconds)
+ timer.setEventHandler { [weak self] in
+ guard let self, self.isRecording else { return }
+ print("[MiniFlow] Auto-stopping at \(Int(Self.maxRecordingSeconds))s")
+ self.stopRecording()
+ }
+ timer.resume()
+ autoStopTimer = timer
+ }
+
+ private func stopRecording() {
+ autoStopTimer?.cancel()
+ autoStopTimer = nil
+ isRecording = false
+ sessionManager.recordingStatus = .processing
+
+ guard let client = sttClient else {
+ sessionManager.recordingStatus = .error
+ sessionManager.errorMessage = "No active STT session"
+ return
+ }
+
+ Task {
+ do {
+ let transcript = try await client.finalize()
+ let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty {
+ sessionManager.recordingStatus = .error
+ sessionManager.errorMessage = "No speech detected"
+ } else {
+ sessionManager.transcriptionResult = trimmed
+ sessionManager.recordingStatus = .done
+ }
+ print("[MiniFlow] Transcription: \(trimmed)")
+ } catch {
+ sessionManager.recordingStatus = .error
+ sessionManager.errorMessage = "Transcription failed"
+ print("[MiniFlow] Transcription error: \(error)")
+ }
+ sttClient = nil
+ }
+ }
+
+ private func cancelRecording() {
+ autoStopTimer?.cancel()
+ autoStopTimer = nil
+ isRecording = false
+ if let client = sttClient {
+ Task { await client.cancel() }
+ sttClient = nil
+ }
+ sessionManager.recordingStatus = .idle
+ sessionManager.partialTranscript = ""
+ print("[MiniFlow] Recording cancelled")
+ }
+}
diff --git a/MiniFlow_iOS/MiniFlow_iOS/Services/FlowSessionManager.swift b/MiniFlow_iOS/MiniFlow_iOS/Services/FlowSessionManager.swift
new file mode 100644
index 0000000..12d2285
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS/Services/FlowSessionManager.swift
@@ -0,0 +1,153 @@
+import Foundation
+
+/// IPC keys — used as filenames in the shared App Group container.
+enum FlowSessionKeys {
+ static let isSessionActive = "flow_session_active"
+ static let sessionHeartbeat = "flow_session_heartbeat"
+ static let recordingCommand = "flow_recording_command"
+ static let transcriptionResult = "flow_transcription_result"
+ static let recordingStatus = "flow_recording_status"
+ static let errorMessage = "flow_error_message"
+ static let partialTranscript = "flow_partial_transcript"
+}
+
+enum RecordingCommand: String {
+ case none, start, stop, cancel
+}
+
+enum FlowRecordingStatus: String {
+ case idle, recording, processing, done, error
+}
+
+/// File-based IPC through the App Group shared container.
+/// UserDefaults is unreliable between app and extension on device,
+/// so we read/write small text files instead.
+class FlowSessionManager {
+ static let shared = FlowSessionManager()
+
+ private let appGroupID = "group.com.smallestai.MiniFlow"
+ private var containerURL: URL?
+ private var heartbeatTimer: Timer?
+
+ private init() {
+ containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
+ if containerURL == nil {
+ print("[FlowSession] ERROR: App Group container not accessible: \(appGroupID)")
+ } else {
+ print("[FlowSession] Container: \(containerURL!.path)")
+ }
+ }
+
+ // MARK: - File helpers
+
+ private func write(_ value: String, forKey key: String) {
+ guard let url = containerURL?.appendingPathComponent(key) else { return }
+ try? value.write(to: url, atomically: true, encoding: .utf8)
+ }
+
+ private func read(forKey key: String) -> String? {
+ guard let url = containerURL?.appendingPathComponent(key) else { return nil }
+ return try? String(contentsOf: url, encoding: .utf8)
+ }
+
+ private func remove(forKey key: String) {
+ guard let url = containerURL?.appendingPathComponent(key) else { return }
+ try? FileManager.default.removeItem(at: url)
+ }
+
+ // MARK: - Heartbeat (Main App Only)
+
+ func startHeartbeat() {
+ heartbeatTimer?.invalidate()
+ updateHeartbeat()
+ heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
+ self?.updateHeartbeat()
+ }
+ }
+
+ func stopHeartbeat() {
+ heartbeatTimer?.invalidate()
+ heartbeatTimer = nil
+ }
+
+ private func updateHeartbeat() {
+ write(String(Date().timeIntervalSince1970), forKey: FlowSessionKeys.sessionHeartbeat)
+ }
+
+ // MARK: - Session State
+
+ var isSessionActive: Bool {
+ get { read(forKey: FlowSessionKeys.isSessionActive) == "true" }
+ set { write(newValue ? "true" : "false", forKey: FlowSessionKeys.isSessionActive) }
+ }
+
+ func startSession() {
+ isSessionActive = true
+ recordingCommand = .none
+ recordingStatus = .idle
+ transcriptionResult = ""
+ errorMessage = ""
+ partialTranscript = ""
+ startHeartbeat()
+ }
+
+ func endSession() {
+ stopHeartbeat()
+ isSessionActive = false
+ recordingCommand = .none
+ recordingStatus = .idle
+ }
+
+ // MARK: - Recording Commands (Keyboard -> App)
+
+ var recordingCommand: RecordingCommand {
+ get { RecordingCommand(rawValue: read(forKey: FlowSessionKeys.recordingCommand) ?? "none") ?? .none }
+ set { write(newValue.rawValue, forKey: FlowSessionKeys.recordingCommand) }
+ }
+
+ func requestStartRecording() { recordingCommand = .start }
+ func requestStopRecording() { recordingCommand = .stop }
+ func clearCommand() { recordingCommand = .none }
+
+ // MARK: - Recording Status (App -> Keyboard)
+
+ var recordingStatus: FlowRecordingStatus {
+ get { FlowRecordingStatus(rawValue: read(forKey: FlowSessionKeys.recordingStatus) ?? "idle") ?? .idle }
+ set { write(newValue.rawValue, forKey: FlowSessionKeys.recordingStatus) }
+ }
+
+ var transcriptionResult: String {
+ get { read(forKey: FlowSessionKeys.transcriptionResult) ?? "" }
+ set { write(newValue, forKey: FlowSessionKeys.transcriptionResult) }
+ }
+
+ var errorMessage: String {
+ get { read(forKey: FlowSessionKeys.errorMessage) ?? "" }
+ set { write(newValue, forKey: FlowSessionKeys.errorMessage) }
+ }
+
+ var partialTranscript: String {
+ get { read(forKey: FlowSessionKeys.partialTranscript) ?? "" }
+ set { write(newValue, forKey: FlowSessionKeys.partialTranscript) }
+ }
+
+ // MARK: - Return App
+
+ var returnAppBundleID: String? {
+ get { read(forKey: "flow_return_app_bundle_id") }
+ set {
+ if let v = newValue { write(v, forKey: "flow_return_app_bundle_id") }
+ else { remove(forKey: "flow_return_app_bundle_id") }
+ }
+ }
+
+ // MARK: - Convenience
+
+ func consumeTranscriptionResult() -> String {
+ let result = transcriptionResult
+ transcriptionResult = ""
+ partialTranscript = ""
+ recordingStatus = .idle
+ return result
+ }
+}
diff --git a/MiniFlow_iOS/MiniFlow_iOS/Services/KeychainHelper.swift b/MiniFlow_iOS/MiniFlow_iOS/Services/KeychainHelper.swift
new file mode 100644
index 0000000..1fbed1a
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS/Services/KeychainHelper.swift
@@ -0,0 +1,63 @@
+import Foundation
+import Security
+
+/// Thin wrapper around the iOS Keychain for storing API keys securely.
+enum KeychainHelper {
+
+ static func save(key: String, service: String) -> Bool {
+ let data = Data(key.utf8)
+ // Delete any existing item first
+ let deleteQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ ]
+ SecItemDelete(deleteQuery as CFDictionary)
+
+ let addQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
+ ]
+ return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess
+ }
+
+ static func load(service: String) -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ var result: AnyObject?
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
+ let data = result as? Data
+ else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+
+ static func delete(service: String) {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+}
+
+// MARK: - MiniFlow-specific keys
+
+extension KeychainHelper {
+ private static let smallestKeyService = "com.smallestai.MiniFlow.smallest-api-key"
+
+ static var smallestAPIKey: String? {
+ get { load(service: smallestKeyService) }
+ set {
+ if let key = newValue {
+ _ = save(key: key, service: smallestKeyService)
+ } else {
+ delete(service: smallestKeyService)
+ }
+ }
+ }
+}
diff --git a/MiniFlow_iOS/MiniFlow_iOS/Services/SmallestAIClient.swift b/MiniFlow_iOS/MiniFlow_iOS/Services/SmallestAIClient.swift
new file mode 100644
index 0000000..ffe1110
--- /dev/null
+++ b/MiniFlow_iOS/MiniFlow_iOS/Services/SmallestAIClient.swift
@@ -0,0 +1,201 @@
+import Foundation
+
+/// Direct WebSocket client for Smallest AI Waves real-time speech-to-text.
+/// Streams raw 16kHz mono 16-bit PCM audio and receives partial/final transcripts.
+actor SmallestAIClient {
+
+ // MARK: - Types
+
+ enum ClientError: LocalizedError {
+ case noAPIKey
+ case connectionFailed(String)
+ case sessionNotActive
+
+ var errorDescription: String? {
+ switch self {
+ case .noAPIKey: return "Smallest AI API key not set."
+ case .connectionFailed(let msg): return "Connection failed: \(msg)"
+ case .sessionNotActive: return "No active transcription session."
+ }
+ }
+ }
+
+ // MARK: - Properties
+
+ private var webSocketTask: URLSessionWebSocketTask?
+ private var isActive = false
+ private var segments: [String] = []
+ private var lastText = ""
+ private var finalizeSent = false
+
+ private var finalContinuation: CheckedContinuation?
+
+ /// Called on the main thread with accumulated transcript text as partials arrive.
+ nonisolated let onPartial: @Sendable (String) -> Void
+
+ private static let wssURL = "wss://api.smallest.ai/waves/v1/pulse/get_text"
+
+ // MARK: - Init
+
+ init(onPartial: @escaping @Sendable (String) -> Void = { _ in }) {
+ self.onPartial = onPartial
+ }
+
+ // MARK: - Session Lifecycle
+
+ /// Opens a WebSocket connection to Smallest AI Waves.
+ func startSession(apiKey: String, language: String = "en") throws {
+ guard !apiKey.isEmpty else { throw ClientError.noAPIKey }
+
+ segments = []
+ lastText = ""
+ finalizeSent = false
+
+ var components = URLComponents(string: Self.wssURL)!
+ components.queryItems = [
+ URLQueryItem(name: "language", value: language),
+ URLQueryItem(name: "encoding", value: "linear16"),
+ URLQueryItem(name: "sample_rate", value: "16000"),
+ ]
+
+ var request = URLRequest(url: components.url!)
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+
+ let session = URLSession(configuration: .default)
+ let task = session.webSocketTask(with: request)
+ task.resume()
+ webSocketTask = task
+ isActive = true
+
+ // Start receive loop
+ Task { await receiveLoop() }
+
+ // Start ping keepalive
+ Task { await pingLoop() }
+ }
+
+ /// Sends a raw PCM audio chunk (binary frame).
+ func sendChunk(_ pcm: Data) {
+ guard isActive, let task = webSocketTask else { return }
+ task.send(.data(pcm)) { _ in }
+ }
+
+ /// Signals end of audio and waits for the final transcript.
+ /// Returns the complete accumulated transcript.
+ func finalize() async throws -> String {
+ guard isActive, let task = webSocketTask else {
+ throw ClientError.sessionNotActive
+ }
+
+ finalizeSent = true
+
+ // Send finalize signal
+ let msg = try JSONSerialization.data(withJSONObject: ["type": "finalize"])
+ let text = String(data: msg, encoding: .utf8)!
+ task.send(.string(text)) { _ in }
+
+ // Wait for final transcript with timeout
+ return try await withCheckedThrowingContinuation { continuation in
+ self.finalContinuation = continuation
+
+ // 15-second timeout
+ Task {
+ try? await Task.sleep(nanoseconds: 15_000_000_000)
+ if let cont = self.finalContinuation {
+ self.finalContinuation = nil
+ let result = self.lastText.isEmpty ? self.joinSegments(self.segments) : self.lastText
+ cont.resume(returning: result)
+ self.close()
+ }
+ }
+ }
+ }
+
+ /// Cancel the session without waiting for a result.
+ func cancel() {
+ finalContinuation?.resume(returning: "")
+ finalContinuation = nil
+ close()
+ }
+
+ // MARK: - Private
+
+ /// Join segments: space between them, but skip space if next starts with punctuation.
+ private func joinSegments(_ segs: [String]) -> String {
+ let trimmed = segs
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ guard !trimmed.isEmpty else { return "" }
+
+ var result = trimmed[0]
+ for i in 1..